From ae8be744e561b9098fc7f71c4a750e2860575ad6 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Tue, 16 Dec 2025 16:35:18 -0500 Subject: [PATCH 1/6] chore: fix spotless checks to work with java 25 --- build.gradle.kts | 5 +- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +- gradlew.bat | 3 +- .../java/org/apache/solr/mcp/server/Main.java | 152 +- .../server/config/McpServerConfiguration.java | 66 +- .../solr/mcp/server/config/SolrConfig.java | 221 +- .../config/SolrConfigurationProperties.java | 97 +- .../mcp/server/indexing/IndexingService.java | 684 +++--- .../documentcreator/CsvDocumentCreator.java | 182 +- .../DocumentProcessingException.java | 87 +- .../documentcreator/FieldNameSanitizer.java | 156 +- .../IndexingDocumentCreator.java | 168 +- .../documentcreator/JsonDocumentCreator.java | 380 ++-- .../documentcreator/SolrDocumentCreator.java | 120 +- .../documentcreator/XmlDocumentCreator.java | 474 ++-- .../server/metadata/CollectionService.java | 1733 +++++++------- .../mcp/server/metadata/CollectionUtils.java | 384 ++-- .../apache/solr/mcp/server/metadata/Dtos.java | 439 ++-- .../mcp/server/metadata/SchemaService.java | 269 ++- .../mcp/server/search/SearchResponse.java | 110 +- .../solr/mcp/server/search/SearchService.java | 434 ++-- .../solr/mcp/server/BuildInfoReader.java | 133 +- .../apache/solr/mcp/server/ClientHttp.java | 8 +- .../apache/solr/mcp/server/ClientStdio.java | 13 +- .../org/apache/solr/mcp/server/MainTest.java | 27 +- .../mcp/server/McpToolRegistrationTest.java | 361 ++- .../apache/solr/mcp/server/SampleClient.java | 334 ++- .../server/TestcontainersConfiguration.java | 27 +- .../mcp/server/config/SolrConfigTest.java | 282 ++- .../DockerImageHttpIntegrationTest.java | 342 ++- .../DockerImageStdioIntegrationTest.java | 250 +- .../mcp/server/indexing/CsvIndexingTest.java | 230 +- .../indexing/IndexingServiceDirectTest.java | 358 ++- .../server/indexing/IndexingServiceTest.java | 2015 ++++++++--------- .../mcp/server/indexing/XmlIndexingTest.java | 955 ++++---- .../CollectionServiceIntegrationTest.java | 451 ++-- .../metadata/CollectionServiceTest.java | 1554 +++++++------ .../server/metadata/CollectionUtilsTest.java | 431 ++-- .../SchemaServiceIntegrationTest.java | 296 ++- .../server/metadata/SchemaServiceTest.java | 218 +- .../search/SearchServiceDirectTest.java | 492 ++-- .../mcp/server/search/SearchServiceTest.java | 1383 ++++++----- 44 files changed, 8231 insertions(+), 8100 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 94f678d..f51a7c1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -152,7 +152,10 @@ tasks.build { spotless { java { target("src/**/*.java") - googleJavaFormat().aosp().reflowLongStrings() + // Use Eclipse JDT formatter to avoid google-java-format's incompatibility + // with cutting-edge JDKs (e.g., 25) which can trigger NoSuchMethodError + // against internal javac classes. + eclipse() removeUnusedImports() trimTrailingWhitespace() endWithNewline() diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..8bdaf60c75ab801e22807dde59e12a8735a34077 100644 GIT binary patch delta 37256 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 delta 35551 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a35649f..4eac4a8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a9..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/main/java/org/apache/solr/mcp/server/Main.java b/src/main/java/org/apache/solr/mcp/server/Main.java index ed4c4d7..54c2973 100644 --- a/src/main/java/org/apache/solr/mcp/server/Main.java +++ b/src/main/java/org/apache/solr/mcp/server/Main.java @@ -24,62 +24,77 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; /** - * Main Spring Boot application class for the Apache Solr Model Context Protocol (MCP) Server. + * Main Spring Boot application class for the Apache Solr Model Context Protocol + * (MCP) Server. * - *

This class serves as the entry point for the Solr MCP Server application, which provides a - * bridge between AI clients (such as Claude Desktop) and Apache Solr search and indexing - * capabilities through the Model Context Protocol specification. + *

+ * This class serves as the entry point for the Solr MCP Server application, + * which provides a bridge between AI clients (such as Claude Desktop) and + * Apache Solr search and indexing capabilities through the Model Context + * Protocol specification. * - *

Application Architecture: + *

+ * Application Architecture: * - *

The application follows a service-oriented architecture where each major Solr operation - * category is encapsulated in its own service class: + *

+ * The application follows a service-oriented architecture where each major Solr + * operation category is encapsulated in its own service class: * *

    - *
  • SearchService: Search operations, faceting, sorting, pagination - *
  • IndexingService: Document indexing, schema-less ingestion, batch - * processing - *
  • CollectionService: Collection management, metrics, health monitoring - *
  • SchemaService: Schema introspection and field management + *
  • SearchService: Search operations, faceting, sorting, + * pagination + *
  • IndexingService: Document indexing, schema-less + * ingestion, batch processing + *
  • CollectionService: Collection management, metrics, + * health monitoring + *
  • SchemaService: Schema introspection and field management *
* - *

Spring Boot Features: + *

+ * Spring Boot Features: * *

    - *
  • Auto-Configuration: Automatic setup of Solr client and service beans - *
  • Property Management: Externalized configuration through - * application.properties - *
  • Dependency Injection: Automatic wiring of service dependencies - *
  • Component Scanning: Automatic discovery of service classes + *
  • Auto-Configuration: Automatic setup of Solr client and + * service beans + *
  • Property Management: Externalized configuration through + * application.properties + *
  • Dependency Injection: Automatic wiring of service + * dependencies + *
  • Component Scanning: Automatic discovery of service + * classes *
* - *

Communication Flow: + *

+ * Communication Flow: * *

    - *
  1. AI client connects to MCP server via stdio - *
  2. Client discovers available tools through MCP protocol - *
  3. Client invokes tools with natural language parameters - *
  4. Server routes requests to appropriate service methods - *
  5. Services interact with Solr via SolrJ client library - *
  6. Results are serialized and returned to AI client + *
  7. AI client connects to MCP server via stdio + *
  8. Client discovers available tools through MCP protocol + *
  9. Client invokes tools with natural language parameters + *
  10. Server routes requests to appropriate service methods + *
  11. Services interact with Solr via SolrJ client library + *
  12. Results are serialized and returned to AI client *
* - *

Configuration Requirements: + *

+ * Configuration Requirements: * - *

The application requires the following configuration properties: + *

+ * The application requires the following configuration properties: * *

{@code
  * # application.properties
  * solr.url=http://localhost:8983
  * }
* - *

Deployment Considerations: + *

+ * Deployment Considerations: * *

    - *
  • Ensure Solr server is running and accessible at configured URL - *
  • Verify network connectivity between MCP server and Solr - *
  • Configure appropriate timeouts for production workloads - *
  • Monitor application logs for connection and performance issues + *
  • Ensure Solr server is running and accessible at configured URL + *
  • Verify network connectivity between MCP server and Solr + *
  • Configure appropriate timeouts for production workloads + *
  • Monitor application logs for connection and performance issues *
* * @version 0.0.1 @@ -93,38 +108,43 @@ @SpringBootApplication public class Main { - /** - * Main application entry point that starts the Spring Boot application. - * - *

This method initializes the Spring application context, configures all service beans, - * establishes Solr connectivity, and begins listening for MCP client connections via standard - * input/output. - * - *

Startup Process: - * - *

    - *
  1. Initialize Spring Boot application context - *
  2. Load configuration properties from various sources - *
  3. Create and configure SolrClient bean - *
  4. Initialize all service beans with dependency injection - *
  5. Register MCP tools from service methods - *
  6. Start MCP server listening on stdio - *
- * - *

Error Handling: - * - *

Startup failures typically indicate configuration issues such as: - * - *

    - *
  • Missing or invalid Solr URL configuration - *
  • Network connectivity issues to Solr server - *
  • Missing required dependencies or classpath issues - *
- * - * @param args command-line arguments passed to the application - * @see SpringApplication#run(Class, String...) - */ - public static void main(String[] args) { - SpringApplication.run(Main.class, args); - } + /** + * Main application entry point that starts the Spring Boot application. + * + *

+ * This method initializes the Spring application context, configures all + * service beans, establishes Solr connectivity, and begins listening for MCP + * client connections via standard input/output. + * + *

+ * Startup Process: + * + *

    + *
  1. Initialize Spring Boot application context + *
  2. Load configuration properties from various sources + *
  3. Create and configure SolrClient bean + *
  4. Initialize all service beans with dependency injection + *
  5. Register MCP tools from service methods + *
  6. Start MCP server listening on stdio + *
+ * + *

+ * Error Handling: + * + *

+ * Startup failures typically indicate configuration issues such as: + * + *

    + *
  • Missing or invalid Solr URL configuration + *
  • Network connectivity issues to Solr server + *
  • Missing required dependencies or classpath issues + *
+ * + * @param args + * command-line arguments passed to the application + * @see SpringApplication#run(Class, String...) + */ + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/config/McpServerConfiguration.java b/src/main/java/org/apache/solr/mcp/server/config/McpServerConfiguration.java index ac26158..5a4b0ab 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/McpServerConfiguration.java +++ b/src/main/java/org/apache/solr/mcp/server/config/McpServerConfiguration.java @@ -37,42 +37,38 @@ @EnableMethodSecurity // ⬅️ enable annotation-driven security class McpServerConfiguration { - @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") - private String issuerUrl; + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUrl; - @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http - // ⬇️ Open every request on the server - .authorizeHttpRequests( - auth -> { - auth.requestMatchers("/actuator").permitAll(); - auth.requestMatchers("/actuator/*").permitAll(); - auth.requestMatchers("/mcp").permitAll(); - auth.anyRequest().authenticated(); - }) - // Configure OAuth2 on the MCP server - .with( - McpServerOAuth2Configurer.mcpServerOAuth2(), - (mcpAuthorization) -> { - // REQUIRED: the issuerURI - mcpAuthorization.authorizationServer(issuerUrl); - }) - // MCP inspector - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(CsrfConfigurer::disable) - .build(); - } + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + // ⬇️ Open every request on the server + .authorizeHttpRequests(auth -> { + auth.requestMatchers("/actuator").permitAll(); + auth.requestMatchers("/actuator/*").permitAll(); + auth.requestMatchers("/mcp").permitAll(); + auth.anyRequest().authenticated(); + }) + // Configure OAuth2 on the MCP server + .with(McpServerOAuth2Configurer.mcpServerOAuth2(), (mcpAuthorization) -> { + // REQUIRED: the issuerURI + mcpAuthorization.authorizationServer(issuerUrl); + }) + // MCP inspector + .cors(cors -> cors.configurationSource(corsConfigurationSource())).csrf(CsrfConfigurer::disable) + .build(); + } - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of("*")); - configuration.setAllowedMethods(List.of("*")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index d4e49cb..8f2ce8a 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -24,40 +24,55 @@ import org.springframework.context.annotation.Configuration; /** - * Spring Configuration class for Apache Solr client setup and connection management. + * Spring Configuration class for Apache Solr client setup and connection + * management. * - *

This configuration class is responsible for creating and configuring the SolrJ client that - * serves as the primary interface for communication with Apache Solr servers. It handles URL - * normalization, connection parameters, and timeout configurations to ensure reliable connectivity - * for the MCP server operations. + *

+ * This configuration class is responsible for creating and configuring the + * SolrJ client that serves as the primary interface for communication with + * Apache Solr servers. It handles URL normalization, connection parameters, and + * timeout configurations to ensure reliable connectivity for the MCP server + * operations. * - *

Configuration Features: + *

+ * Configuration Features: * *

    - *
  • Automatic URL Normalization: Ensures proper Solr URL formatting - *
  • Connection Timeout Management: Configurable timeouts for reliability - *
  • Property Integration: Uses externalized configuration through properties - *
  • Production-Ready Defaults: Optimized timeout values for production use + *
  • Automatic URL Normalization: Ensures proper Solr URL + * formatting + *
  • Connection Timeout Management: Configurable timeouts for + * reliability + *
  • Property Integration: Uses externalized configuration + * through properties + *
  • Production-Ready Defaults: Optimized timeout values for + * production use *
* - *

URL Processing: + *

+ * URL Processing: * - *

The configuration automatically normalizes Solr URLs to ensure proper communication: + *

+ * The configuration automatically normalizes Solr URLs to ensure proper + * communication: * *

    - *
  • Adds trailing slashes if missing - *
  • Appends "/solr/" path if not present in the URL - *
  • Handles various URL formats (with/without protocols, paths, etc.) + *
  • Adds trailing slashes if missing + *
  • Appends "/solr/" path if not present in the URL + *
  • Handles various URL formats (with/without protocols, paths, etc.) *
* - *

Connection Parameters: + *

+ * Connection Parameters: * *

    - *
  • Connection Timeout: 10 seconds (10,000ms) for establishing connections - *
  • Socket Timeout: 60 seconds (60,000ms) for read operations + *
  • Connection Timeout: 10 seconds (10,000ms) for + * establishing connections + *
  • Socket Timeout: 60 seconds (60,000ms) for read + * operations *
* - *

Configuration Example: + *

+ * Configuration Example: * *

{@code
  * # application.properties
@@ -66,13 +81,15 @@
  * # Results in normalized URL: http://localhost:8983/solr/
  * }
* - *

Supported URL Formats: + *

+ * Supported URL Formats: * *

    - *
  • {@code http://localhost:8983} → {@code http://localhost:8983/solr/} - *
  • {@code http://localhost:8983/} → {@code http://localhost:8983/solr/} - *
  • {@code http://localhost:8983/solr} → {@code http://localhost:8983/solr/} - *
  • {@code http://localhost:8983/solr/} → {@code http://localhost:8983/solr/} (unchanged) + *
  • {@code http://localhost:8983} → {@code http://localhost:8983/solr/} + *
  • {@code http://localhost:8983/} → {@code http://localhost:8983/solr/} + *
  • {@code http://localhost:8983/solr} → {@code http://localhost:8983/solr/} + *
  • {@code http://localhost:8983/solr/} → {@code http://localhost:8983/solr/} + * (unchanged) *
* * @version 0.0.1 @@ -85,80 +102,92 @@ @EnableConfigurationProperties(SolrConfigurationProperties.class) public class SolrConfig { - private static final int CONNECTION_TIMEOUT_MS = 10000; - private static final int SOCKET_TIMEOUT_MS = 60000; - private static final String SOLR_PATH = "solr/"; + private static final int CONNECTION_TIMEOUT_MS = 10000; + private static final int SOCKET_TIMEOUT_MS = 60000; + private static final String SOLR_PATH = "solr/"; - /** - * Creates and configures a SolrClient bean for Apache Solr communication. - * - *

This method serves as the primary factory for creating SolrJ client instances that are - * used throughout the application for all Solr operations. It performs automatic URL - * normalization and applies production-ready timeout configurations. - * - *

URL Normalization Process: - * - *

    - *
  1. Trailing Slash: Ensures URL ends with "/" - *
  2. Solr Path: Appends "/solr/" if not already present - *
  3. Validation: Checks for proper Solr endpoint format - *
- * - *

Connection Configuration: - * - *

    - *
  • Connection Timeout: 10,000ms - Time to establish initial connection - *
  • Socket Timeout: 60,000ms - Time to wait for data/response - *
- * - *

Client Type: - * - *

Creates an {@code HttpSolrClient} configured for standard HTTP-based communication with - * Solr servers. This client type is suitable for both standalone Solr instances and SolrCloud - * deployments when used with load balancers. - * - *

Error Handling: - * - *

URL normalization is defensive and handles various input formats gracefully. Invalid URLs - * or connection failures will be caught during application startup or first usage, providing - * clear error messages for troubleshooting. - * - *

Production Considerations: - * - *

    - *
  • Timeout values are optimized for production workloads - *
  • Connection pooling is handled by the HttpSolrClient internally - *
  • Client is thread-safe and suitable for concurrent operations - *
- * - * @param properties the injected Solr configuration properties containing connection URL - * @return configured SolrClient instance ready for use in application services - * @see Http2SolrClient.Builder - * @see SolrConfigurationProperties#url() - */ - @Bean - SolrClient solrClient(SolrConfigurationProperties properties) { - String url = properties.url(); + /** + * Creates and configures a SolrClient bean for Apache Solr communication. + * + *

+ * This method serves as the primary factory for creating SolrJ client instances + * that are used throughout the application for all Solr operations. It performs + * automatic URL normalization and applies production-ready timeout + * configurations. + * + *

+ * URL Normalization Process: + * + *

    + *
  1. Trailing Slash: Ensures URL ends with "/" + *
  2. Solr Path: Appends "/solr/" if not already present + *
  3. Validation: Checks for proper Solr endpoint format + *
+ * + *

+ * Connection Configuration: + * + *

    + *
  • Connection Timeout: 10,000ms - Time to establish initial + * connection + *
  • Socket Timeout: 60,000ms - Time to wait for + * data/response + *
+ * + *

+ * Client Type: + * + *

+ * Creates an {@code HttpSolrClient} configured for standard HTTP-based + * communication with Solr servers. This client type is suitable for both + * standalone Solr instances and SolrCloud deployments when used with load + * balancers. + * + *

+ * Error Handling: + * + *

+ * URL normalization is defensive and handles various input formats gracefully. + * Invalid URLs or connection failures will be caught during application startup + * or first usage, providing clear error messages for troubleshooting. + * + *

+ * Production Considerations: + * + *

    + *
  • Timeout values are optimized for production workloads + *
  • Connection pooling is handled by the HttpSolrClient internally + *
  • Client is thread-safe and suitable for concurrent operations + *
+ * + * @param properties + * the injected Solr configuration properties containing connection + * URL + * @return configured SolrClient instance ready for use in application services + * @see Http2SolrClient.Builder + * @see SolrConfigurationProperties#url() + */ + @Bean + SolrClient solrClient(SolrConfigurationProperties properties) { + String url = properties.url(); - // Ensure URL is properly formatted for Solr - // The URL should end with /solr/ for proper path construction - if (!url.endsWith("/")) { - url = url + "/"; - } + // Ensure URL is properly formatted for Solr + // The URL should end with /solr/ for proper path construction + if (!url.endsWith("/")) { + url = url + "/"; + } - // If URL doesn't contain /solr/ path, add it - if (!url.endsWith("/" + SOLR_PATH) && !url.contains("/" + SOLR_PATH)) { - if (url.endsWith("/")) { - url = url + SOLR_PATH; - } else { - url = url + "/" + SOLR_PATH; - } - } + // If URL doesn't contain /solr/ path, add it + if (!url.endsWith("/" + SOLR_PATH) && !url.contains("/" + SOLR_PATH)) { + if (url.endsWith("/")) { + url = url + SOLR_PATH; + } else { + url = url + "/" + SOLR_PATH; + } + } - // Use with explicit base URL - return new Http2SolrClient.Builder(url) - .withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .build(); - } + // Use with explicit base URL + return new Http2SolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).build(); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java index abbaf2f..2d70aa7 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java @@ -19,51 +19,66 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Spring Boot Configuration Properties record for Apache Solr connection settings. + * Spring Boot Configuration Properties record for Apache Solr connection + * settings. * - *

This immutable configuration record encapsulates all external configuration properties - * required for establishing and maintaining connections to Apache Solr servers. It follows Spring - * Boot's type-safe configuration properties pattern using Java records for enhanced immutability - * and reduced boilerplate. + *

+ * This immutable configuration record encapsulates all external configuration + * properties required for establishing and maintaining connections to Apache + * Solr servers. It follows Spring Boot's type-safe configuration properties + * pattern using Java records for enhanced immutability and reduced boilerplate. * - *

Configuration Binding: + *

+ * Configuration Binding: * - *

This record automatically binds to configuration properties with the "solr" prefix from - * various configuration sources including: + *

+ * This record automatically binds to configuration properties with the "solr" + * prefix from various configuration sources including: * *

    - *
  • application.properties: {@code solr.url=http://localhost:8983} - *
  • application.yml: {@code solr: url: http://localhost:8983} - *
  • Environment Variables: {@code SOLR_URL=http://localhost:8983} - *
  • Command Line Arguments: {@code --solr.url=http://localhost:8983} + *
  • application.properties: + * {@code solr.url=http://localhost:8983} + *
  • application.yml: + * {@code solr: url: http://localhost:8983} + *
  • Environment Variables: + * {@code SOLR_URL=http://localhost:8983} + *
  • Command Line Arguments: + * {@code --solr.url=http://localhost:8983} *
* - *

Record Benefits: + *

+ * Record Benefits: * *

    - *
  • Immutability: Properties cannot be modified after construction - *
  • Type Safety: Compile-time validation of property types - *
  • Automatic Generation: Constructor, getters, equals, hashCode, toString - *
  • Validation Support: Compatible with Spring Boot validation annotations + *
  • Immutability: Properties cannot be modified after + * construction + *
  • Type Safety: Compile-time validation of property types + *
  • Automatic Generation: Constructor, getters, equals, + * hashCode, toString + *
  • Validation Support: Compatible with Spring Boot + * validation annotations *
* - *

URL Format Requirements: + *

+ * URL Format Requirements: * - *

The Solr URL should point to the base Solr server endpoint. The configuration system will - * automatically normalize URLs to ensure proper formatting: + *

+ * The Solr URL should point to the base Solr server endpoint. The configuration + * system will automatically normalize URLs to ensure proper formatting: * *

    - *
  • Valid Examples: - *
      - *
    • {@code http://localhost:8983} - *
    • {@code http://localhost:8983/} - *
    • {@code http://localhost:8983/solr} - *
    • {@code http://localhost:8983/solr/} - *
    • {@code https://solr.example.com:8983} - *
    + *
  • Valid Examples: + *
      + *
    • {@code http://localhost:8983} + *
    • {@code http://localhost:8983/} + *
    • {@code http://localhost:8983/solr} + *
    • {@code http://localhost:8983/solr/} + *
    • {@code https://solr.example.com:8983} + *
    *
* - *

Environment-Specific Configuration: + *

+ * Environment-Specific Configuration: * *

{@code
  * # Development
@@ -76,17 +91,24 @@
  * solr.url=https://solr-prod.company.com:8983
  * }
* - *

Integration with Dependency Injection: + *

+ * Integration with Dependency Injection: * - *

This record is automatically instantiated by Spring Boot's configuration properties mechanism - * and can be injected into any Spring-managed component that requires Solr connection information. + *

+ * This record is automatically instantiated by Spring Boot's configuration + * properties mechanism and can be injected into any Spring-managed component + * that requires Solr connection information. * - *

Validation Considerations: + *

+ * Validation Considerations: * - *

While basic validation is handled by the configuration system, additional URL validation and - * normalization occurs in the {@link SolrConfig} class during SolrClient bean creation. + *

+ * While basic validation is handled by the configuration system, additional URL + * validation and normalization occurs in the {@link SolrConfig} class during + * SolrClient bean creation. * - * @param url the base URL of the Apache Solr server (required, non-null) + * @param url + * the base URL of the Apache Solr server (required, non-null) * @version 0.0.1 * @since 0.0.1 * @see SolrConfig @@ -94,4 +116,5 @@ * @see org.springframework.boot.context.properties.EnableConfigurationProperties */ @ConfigurationProperties(prefix = "solr") -public record SolrConfigurationProperties(String url) {} +public record SolrConfigurationProperties(String url) { +} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java index c7869ac..045dd72 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java @@ -30,47 +30,63 @@ import org.xml.sax.SAXException; /** - * Spring Service providing comprehensive document indexing capabilities for Apache Solr collections - * through Model Context Protocol (MCP) integration. + * Spring Service providing comprehensive document indexing capabilities for + * Apache Solr collections through Model Context Protocol (MCP) integration. * - *

This service handles the conversion of JSON, CSV, and XML documents into Solr-compatible - * format and manages the indexing process with robust error handling and batch processing - * capabilities. It employs a schema-less approach where Solr automatically detects field types, - * eliminating the need for predefined schema configuration. + *

+ * This service handles the conversion of JSON, CSV, and XML documents into + * Solr-compatible format and manages the indexing process with robust error + * handling and batch processing capabilities. It employs a schema-less approach + * where Solr automatically detects field types, eliminating the need for + * predefined schema configuration. * - *

Core Features: + *

+ * Core Features: * *

    - *
  • Schema-less Indexing: Automatic field type detection by Solr - *
  • JSON Processing: Support for complex nested JSON documents - *
  • CSV Processing: Support for comma-separated value files with headers - *
  • XML Processing: Support for XML documents with element flattening and - * attribute handling - *
  • Batch Processing: Efficient bulk indexing with configurable batch sizes - *
  • Error Resilience: Individual document fallback when batch operations fail - *
  • Field Sanitization: Automatic cleanup of field names for Solr - * compatibility + *
  • Schema-less Indexing: Automatic field type detection by + * Solr + *
  • JSON Processing: Support for complex nested JSON + * documents + *
  • CSV Processing: Support for comma-separated value files + * with headers + *
  • XML Processing: Support for XML documents with element + * flattening and attribute handling + *
  • Batch Processing: Efficient bulk indexing with + * configurable batch sizes + *
  • Error Resilience: Individual document fallback when + * batch operations fail + *
  • Field Sanitization: Automatic cleanup of field names for + * Solr compatibility *
* - *

MCP Tool Integration: + *

+ * MCP Tool Integration: * - *

The service exposes indexing functionality as MCP tools that can be invoked by AI clients - * through natural language requests. This enables seamless document ingestion workflows from - * external data sources. + *

+ * The service exposes indexing functionality as MCP tools that can be invoked + * by AI clients through natural language requests. This enables seamless + * document ingestion workflows from external data sources. * - *

JSON Document Processing: + *

+ * JSON Document Processing: * - *

The service processes JSON documents by flattening nested objects using underscore notation - * (e.g., "user.name" becomes "user_name") and handles arrays by converting them to multi-valued - * fields that Solr natively supports. + *

+ * The service processes JSON documents by flattening nested objects using + * underscore notation (e.g., "user.name" becomes "user_name") and handles + * arrays by converting them to multi-valued fields that Solr natively supports. * - *

Batch Processing Strategy: + *

+ * Batch Processing Strategy: * - *

Uses configurable batch sizes (default 1000 documents) for optimal performance. If a batch - * fails, the service automatically retries by indexing documents individually to identify and skip - * problematic documents while preserving valid ones. + *

+ * Uses configurable batch sizes (default 1000 documents) for optimal + * performance. If a batch fails, the service automatically retries by indexing + * documents individually to identify and skip problematic documents while + * preserving valid ones. * - *

Example Usage: + *

+ * Example Usage: * *

{@code
  * // Index JSON array of documents
@@ -91,291 +107,347 @@
 @Service
 public class IndexingService {
 
-    private static final int DEFAULT_BATCH_SIZE = 1000;
+	private static final int DEFAULT_BATCH_SIZE = 1000;
 
-    /** SolrJ client for communicating with Solr server */
-    private final SolrClient solrClient;
+	/** SolrJ client for communicating with Solr server */
+	private final SolrClient solrClient;
 
-    /** Service for creating SolrInputDocument objects from various data formats */
-    private final IndexingDocumentCreator indexingDocumentCreator;
+	/** Service for creating SolrInputDocument objects from various data formats */
+	private final IndexingDocumentCreator indexingDocumentCreator;
 
-    /**
-     * Constructs a new IndexingService with the required dependencies.
-     *
-     * 

This constructor is automatically called by Spring's dependency injection framework during - * application startup, providing the service with the necessary Solr client and configuration - * components. - * - * @param solrClient the SolrJ client instance for communicating with Solr - * @see SolrClient - */ - public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDocumentCreator) { - this.solrClient = solrClient; - this.indexingDocumentCreator = indexingDocumentCreator; - } + /** + * Constructs a new IndexingService with the required dependencies. + * + *

+ * This constructor is automatically called by Spring's dependency injection + * framework during application startup, providing the service with the + * necessary Solr client and configuration components. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + * @see SolrClient + */ + public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDocumentCreator) { + this.solrClient = solrClient; + this.indexingDocumentCreator = indexingDocumentCreator; + } - /** - * Indexes documents from a JSON string into a specified Solr collection. - * - *

This method serves as the primary entry point for document indexing operations and is - * exposed as an MCP tool for AI client interactions. It processes JSON data containing document - * arrays and indexes them using a schema-less approach. - * - *

Supported JSON Formats: - * - *

    - *
  • Document Array: {@code [{"field1":"value1"},{"field2":"value2"}]} - *
  • Nested Objects: Automatically flattened with underscore notation - *
  • Multi-valued Fields: Arrays converted to Solr multi-valued fields - *
- * - *

Processing Workflow: - * - *

    - *
  1. Parse JSON string into structured documents - *
  2. Convert to schema-less SolrInputDocument objects - *
  3. Execute batch indexing with error handling - *
  4. Commit changes to make documents searchable - *
- * - *

MCP Tool Usage: - * - *

AI clients can invoke this method with natural language requests like "index these - * documents into my_collection" or "add this JSON data to the search index". - * - *

Error Handling: - * - *

If indexing fails, the method attempts individual document processing to maximize the - * number of successfully indexed documents. Detailed error information is logged for - * troubleshooting purposes. - * - * @param collection the name of the Solr collection to index documents into - * @param json JSON string containing an array of documents to index - * @throws IOException if there are critical errors in JSON parsing or Solr communication - * @throws SolrServerException if Solr server encounters errors during indexing - * @see IndexingDocumentCreator#createSchemalessDocumentsFromJson(String) - * @see #indexDocuments(String, List) - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "index_json_documents", - description = "Index documents from json String into Solr collection") - public void indexJsonDocuments( - @McpToolParam(description = "Solr collection to index into") String collection, - @McpToolParam(description = "JSON string containing documents to index") String json) - throws IOException, SolrServerException { - List schemalessDoc = - indexingDocumentCreator.createSchemalessDocumentsFromJson(json); - indexDocuments(collection, schemalessDoc); - } + /** + * Indexes documents from a JSON string into a specified Solr collection. + * + *

+ * This method serves as the primary entry point for document indexing + * operations and is exposed as an MCP tool for AI client interactions. It + * processes JSON data containing document arrays and indexes them using a + * schema-less approach. + * + *

+ * Supported JSON Formats: + * + *

    + *
  • Document Array: + * {@code [{"field1":"value1"},{"field2":"value2"}]} + *
  • Nested Objects: Automatically flattened with underscore + * notation + *
  • Multi-valued Fields: Arrays converted to Solr + * multi-valued fields + *
+ * + *

+ * Processing Workflow: + * + *

    + *
  1. Parse JSON string into structured documents + *
  2. Convert to schema-less SolrInputDocument objects + *
  3. Execute batch indexing with error handling + *
  4. Commit changes to make documents searchable + *
+ * + *

+ * MCP Tool Usage: + * + *

+ * AI clients can invoke this method with natural language requests like "index + * these documents into my_collection" or "add this JSON data to the search + * index". + * + *

+ * Error Handling: + * + *

+ * If indexing fails, the method attempts individual document processing to + * maximize the number of successfully indexed documents. Detailed error + * information is logged for troubleshooting purposes. + * + * @param collection + * the name of the Solr collection to index documents into + * @param json + * JSON string containing an array of documents to index + * @throws IOException + * if there are critical errors in JSON parsing or Solr + * communication + * @throws SolrServerException + * if Solr server encounters errors during indexing + * @see IndexingDocumentCreator#createSchemalessDocumentsFromJson(String) + * @see #indexDocuments(String, List) + */ + @PreAuthorize("isAuthenticated()") + @McpTool(name = "index_json_documents", description = "Index documents from json String into Solr collection") + public void indexJsonDocuments(@McpToolParam(description = "Solr collection to index into") String collection, + @McpToolParam(description = "JSON string containing documents to index") String json) + throws IOException, SolrServerException { + List schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + indexDocuments(collection, schemalessDoc); + } - /** - * Indexes documents from a CSV string into a specified Solr collection. - * - *

This method serves as the primary entry point for CSV document indexing operations and is - * exposed as an MCP tool for AI client interactions. It processes CSV data with headers and - * indexes them using a schema-less approach. - * - *

Supported CSV Formats: - * - *

    - *
  • Header Row Required: First row must contain column names - *
  • Comma Delimited: Standard CSV format with comma separators - *
  • Mixed Data Types: Automatic type detection by Solr - *
- * - *

Processing Workflow: - * - *

    - *
  1. Parse CSV string to extract headers and data rows - *
  2. Convert to schema-less SolrInputDocument objects - *
  3. Execute batch indexing with error handling - *
  4. Commit changes to make documents searchable - *
- * - *

MCP Tool Usage: - * - *

AI clients can invoke this method with natural language requests like "index this CSV data - * into my_collection" or "add these CSV records to the search index". - * - *

Error Handling: - * - *

If indexing fails, the method attempts individual document processing to maximize the - * number of successfully indexed documents. Detailed error information is logged for - * troubleshooting purposes. - * - * @param collection the name of the Solr collection to index documents into - * @param csv CSV string containing documents to index (first row must be headers) - * @throws IOException if there are critical errors in CSV parsing or Solr communication - * @throws SolrServerException if Solr server encounters errors during indexing - * @see IndexingDocumentCreator#createSchemalessDocumentsFromCsv(String) - * @see #indexDocuments(String, List) - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "index_csv_documents", - description = "Index documents from CSV string into Solr collection") - public void indexCsvDocuments( - @McpToolParam(description = "Solr collection to index into") String collection, - @McpToolParam(description = "CSV string containing documents to index") String csv) - throws IOException, SolrServerException { - List schemalessDoc = - indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv); - indexDocuments(collection, schemalessDoc); - } + /** + * Indexes documents from a CSV string into a specified Solr collection. + * + *

+ * This method serves as the primary entry point for CSV document indexing + * operations and is exposed as an MCP tool for AI client interactions. It + * processes CSV data with headers and indexes them using a schema-less + * approach. + * + *

+ * Supported CSV Formats: + * + *

    + *
  • Header Row Required: First row must contain column names + *
  • Comma Delimited: Standard CSV format with comma + * separators + *
  • Mixed Data Types: Automatic type detection by Solr + *
+ * + *

+ * Processing Workflow: + * + *

    + *
  1. Parse CSV string to extract headers and data rows + *
  2. Convert to schema-less SolrInputDocument objects + *
  3. Execute batch indexing with error handling + *
  4. Commit changes to make documents searchable + *
+ * + *

+ * MCP Tool Usage: + * + *

+ * AI clients can invoke this method with natural language requests like "index + * this CSV data into my_collection" or "add these CSV records to the search + * index". + * + *

+ * Error Handling: + * + *

+ * If indexing fails, the method attempts individual document processing to + * maximize the number of successfully indexed documents. Detailed error + * information is logged for troubleshooting purposes. + * + * @param collection + * the name of the Solr collection to index documents into + * @param csv + * CSV string containing documents to index (first row must be + * headers) + * @throws IOException + * if there are critical errors in CSV parsing or Solr communication + * @throws SolrServerException + * if Solr server encounters errors during indexing + * @see IndexingDocumentCreator#createSchemalessDocumentsFromCsv(String) + * @see #indexDocuments(String, List) + */ + @PreAuthorize("isAuthenticated()") + @McpTool(name = "index_csv_documents", description = "Index documents from CSV string into Solr collection") + public void indexCsvDocuments(@McpToolParam(description = "Solr collection to index into") String collection, + @McpToolParam(description = "CSV string containing documents to index") String csv) + throws IOException, SolrServerException { + List schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv); + indexDocuments(collection, schemalessDoc); + } - /** - * Indexes documents from an XML string into a specified Solr collection. - * - *

This method serves as the primary entry point for XML document indexing operations and is - * exposed as an MCP tool for AI client interactions. It processes XML data with nested elements - * and attributes, indexing them using a schema-less approach. - * - *

Supported XML Formats: - * - *

    - *
  • Single Document: Root element treated as one document - *
  • Multiple Documents: Child elements with 'doc', 'item', or 'record' - * names treated as separate documents - *
  • Nested Elements: Automatically flattened with underscore notation - *
  • Attributes: Converted to fields with "_attr" suffix - *
  • Mixed Data Types: Automatic type detection by Solr - *
- * - *

Processing Workflow: - * - *

    - *
  1. Parse XML string to extract elements and attributes - *
  2. Flatten nested structures using underscore notation - *
  3. Convert to schema-less SolrInputDocument objects - *
  4. Execute batch indexing with error handling - *
  5. Commit changes to make documents searchable - *
- * - *

MCP Tool Usage: - * - *

AI clients can invoke this method with natural language requests like "index this XML data - * into my_collection" or "add these XML records to the search index". - * - *

Error Handling: - * - *

If indexing fails, the method attempts individual document processing to maximize the - * number of successfully indexed documents. Detailed error information is logged for - * troubleshooting purposes. - * - *

Example XML Processing: - * - *

{@code
-     * Input:
-     * 
-     *   
-     *     Sample
-     *     
-     *       John Doe
-     *     
-     *   
-     * 
-     *
-     * Result: {id_attr:"1", title:"Sample", author_name:"John Doe"}
-     * }
- * - * @param collection the name of the Solr collection to index documents into - * @param xml XML string containing documents to index - * @throws ParserConfigurationException if XML parser configuration fails - * @throws SAXException if XML parsing fails due to malformed content - * @throws IOException if I/O errors occur during parsing or Solr communication - * @throws SolrServerException if Solr server encounters errors during indexing - * @see IndexingDocumentCreator#createSchemalessDocumentsFromXml(String) - * @see #indexDocuments(String, List) - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "index_xml_documents", - description = "Index documents from XML string into Solr collection") - public void indexXmlDocuments( - @McpToolParam(description = "Solr collection to index into") String collection, - @McpToolParam(description = "XML string containing documents to index") String xml) - throws ParserConfigurationException, SAXException, IOException, SolrServerException { - List schemalessDoc = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xml); - indexDocuments(collection, schemalessDoc); - } + /** + * Indexes documents from an XML string into a specified Solr collection. + * + *

+ * This method serves as the primary entry point for XML document indexing + * operations and is exposed as an MCP tool for AI client interactions. It + * processes XML data with nested elements and attributes, indexing them using a + * schema-less approach. + * + *

+ * Supported XML Formats: + * + *

    + *
  • Single Document: Root element treated as one document + *
  • Multiple Documents: Child elements with 'doc', 'item', + * or 'record' names treated as separate documents + *
  • Nested Elements: Automatically flattened with underscore + * notation + *
  • Attributes: Converted to fields with "_attr" suffix + *
  • Mixed Data Types: Automatic type detection by Solr + *
+ * + *

+ * Processing Workflow: + * + *

    + *
  1. Parse XML string to extract elements and attributes + *
  2. Flatten nested structures using underscore notation + *
  3. Convert to schema-less SolrInputDocument objects + *
  4. Execute batch indexing with error handling + *
  5. Commit changes to make documents searchable + *
+ * + *

+ * MCP Tool Usage: + * + *

+ * AI clients can invoke this method with natural language requests like "index + * this XML data into my_collection" or "add these XML records to the search + * index". + * + *

+ * Error Handling: + * + *

+ * If indexing fails, the method attempts individual document processing to + * maximize the number of successfully indexed documents. Detailed error + * information is logged for troubleshooting purposes. + * + *

+ * Example XML Processing: + * + *

{@code
+	 * Input:
+	 * 
+	 *   
+	 *     Sample
+	 *     
+	 *       John Doe
+	 *     
+	 *   
+	 * 
+	 *
+	 * Result: {id_attr:"1", title:"Sample", author_name:"John Doe"}
+	 * }
+ * + * @param collection + * the name of the Solr collection to index documents into + * @param xml + * XML string containing documents to index + * @throws ParserConfigurationException + * if XML parser configuration fails + * @throws SAXException + * if XML parsing fails due to malformed content + * @throws IOException + * if I/O errors occur during parsing or Solr communication + * @throws SolrServerException + * if Solr server encounters errors during indexing + * @see IndexingDocumentCreator#createSchemalessDocumentsFromXml(String) + * @see #indexDocuments(String, List) + */ + @PreAuthorize("isAuthenticated()") + @McpTool(name = "index_xml_documents", description = "Index documents from XML string into Solr collection") + public void indexXmlDocuments(@McpToolParam(description = "Solr collection to index into") String collection, + @McpToolParam(description = "XML string containing documents to index") String xml) + throws ParserConfigurationException, SAXException, IOException, SolrServerException { + List schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromXml(xml); + indexDocuments(collection, schemalessDoc); + } - /** - * Indexes a list of SolrInputDocument objects into a Solr collection using batch processing. - * - *

This method implements a robust batch indexing strategy that optimizes performance while - * providing resilience against individual document failures. It processes documents in - * configurable batches and includes fallback mechanisms for error recovery. - * - *

Batch Processing Strategy: - * - *

    - *
  • Batch Size: Configurable (default 1000) for optimal performance - *
  • Error Recovery: Individual document retry on batch failure - *
  • Success Tracking: Accurate count of successfully indexed documents - *
  • Commit Strategy: Single commit after all batches for consistency - *
- * - *

Error Handling Workflow: - * - *

    - *
  1. Attempt batch indexing for optimal performance - *
  2. On batch failure, retry each document individually - *
  3. Track successful vs failed document counts - *
  4. Continue processing remaining batches despite failures - *
  5. Commit all successful changes at the end - *
- * - *

Performance Considerations: - * - *

Batch processing significantly improves indexing performance compared to individual - * document operations. The fallback to individual processing ensures maximum document ingestion - * even when some documents have issues. - * - *

Transaction Behavior: - * - *

The method commits changes after all batches are processed, making indexed documents - * immediately searchable. This ensures atomicity at the operation level while maintaining - * performance through batching. - * - * @param collection the name of the Solr collection to index into - * @param documents list of SolrInputDocument objects to index - * @return the number of documents successfully indexed - * @throws SolrServerException if there are critical errors in Solr communication - * @throws IOException if there are critical errors in commit operations - * @see SolrInputDocument - * @see SolrClient#add(String, java.util.Collection) - * @see SolrClient#commit(String) - */ - public int indexDocuments(String collection, List documents) - throws SolrServerException, IOException { - int successCount = 0; - final int batchSize = DEFAULT_BATCH_SIZE; + /** + * Indexes a list of SolrInputDocument objects into a Solr collection using + * batch processing. + * + *

+ * This method implements a robust batch indexing strategy that optimizes + * performance while providing resilience against individual document failures. + * It processes documents in configurable batches and includes fallback + * mechanisms for error recovery. + * + *

+ * Batch Processing Strategy: + * + *

    + *
  • Batch Size: Configurable (default 1000) for optimal + * performance + *
  • Error Recovery: Individual document retry on batch + * failure + *
  • Success Tracking: Accurate count of successfully indexed + * documents + *
  • Commit Strategy: Single commit after all batches for + * consistency + *
+ * + *

+ * Error Handling Workflow: + * + *

    + *
  1. Attempt batch indexing for optimal performance + *
  2. On batch failure, retry each document individually + *
  3. Track successful vs failed document counts + *
  4. Continue processing remaining batches despite failures + *
  5. Commit all successful changes at the end + *
+ * + *

+ * Performance Considerations: + * + *

+ * Batch processing significantly improves indexing performance compared to + * individual document operations. The fallback to individual processing ensures + * maximum document ingestion even when some documents have issues. + * + *

+ * Transaction Behavior: + * + *

+ * The method commits changes after all batches are processed, making indexed + * documents immediately searchable. This ensures atomicity at the operation + * level while maintaining performance through batching. + * + * @param collection + * the name of the Solr collection to index into + * @param documents + * list of SolrInputDocument objects to index + * @return the number of documents successfully indexed + * @throws SolrServerException + * if there are critical errors in Solr communication + * @throws IOException + * if there are critical errors in commit operations + * @see SolrInputDocument + * @see SolrClient#add(String, java.util.Collection) + * @see SolrClient#commit(String) + */ + public int indexDocuments(String collection, List documents) + throws SolrServerException, IOException { + int successCount = 0; + final int batchSize = DEFAULT_BATCH_SIZE; - for (int i = 0; i < documents.size(); i += batchSize) { - final int endIndex = Math.min(i + batchSize, documents.size()); - final List batch = documents.subList(i, endIndex); + for (int i = 0; i < documents.size(); i += batchSize) { + final int endIndex = Math.min(i + batchSize, documents.size()); + final List batch = documents.subList(i, endIndex); - try { - solrClient.add(collection, batch); - successCount += batch.size(); - } catch (SolrServerException | IOException | RuntimeException e) { - // Try indexing documents individually to identify problematic ones - for (SolrInputDocument doc : batch) { - try { - solrClient.add(collection, doc); - successCount++; - } catch (SolrServerException | IOException | RuntimeException docError) { - // Document failed to index - this is expected behavior for problematic - // documents - // We continue processing the rest of the batch - } - } - } - } + try { + solrClient.add(collection, batch); + successCount += batch.size(); + } catch (SolrServerException | IOException | RuntimeException e) { + // Try indexing documents individually to identify problematic ones + for (SolrInputDocument doc : batch) { + try { + solrClient.add(collection, doc); + successCount++; + } catch (SolrServerException | IOException | RuntimeException docError) { + // Document failed to index - this is expected behavior for problematic + // documents + // We continue processing the rest of the batch + } + } + } + } - solrClient.commit(collection); - return successCount; - } + solrClient.commit(collection); + return successCount; + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java index d1f84ea..038fc76 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java @@ -28,101 +28,115 @@ import org.springframework.stereotype.Component; /** - * Utility class for processing CSV documents and converting them to SolrInputDocument objects. + * Utility class for processing CSV documents and converting them to + * SolrInputDocument objects. * - *

This class handles the conversion of CSV documents into Solr-compatible format using a - * schema-less approach where Solr automatically detects field types. + *

+ * This class handles the conversion of CSV documents into Solr-compatible + * format using a schema-less approach where Solr automatically detects field + * types. */ @Component public class CsvDocumentCreator implements SolrDocumentCreator { - private static final int MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024; + private static final int MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024; - /** - * Creates a list of schema-less SolrInputDocument objects from a CSV string. - * - *

This method implements a flexible document conversion strategy that allows Solr to - * automatically detect field types without requiring predefined schema configuration. It - * processes CSV data by using the first row as field headers and converting each subsequent row - * into a document. - * - *

Schema-less Benefits: - * - *

    - *
  • Flexibility: No need to predefine field types in schema - *
  • Rapid Prototyping: Quick iteration on document structures - *
  • Type Detection: Solr automatically infers optimal field types - *
  • Dynamic Fields: Support for varying document structures - *
- * - *

CSV Processing Rules: - * - *

    - *
  • Header Row: First row defines field names, automatically sanitized - *
  • Empty Values: Ignored and not indexed - *
  • Type Detection: Solr handles numeric, boolean, and string types - * automatically - *
  • Field Sanitization: Column names cleaned for Solr compatibility - *
- * - *

Field Name Sanitization: - * - *

Field names are automatically sanitized to ensure Solr compatibility by removing special - * characters and converting to lowercase with underscore separators. - * - *

Example Transformation: - * - *

{@code
-     * Input CSV:
-     * id,name,price,inStock
-     * 123,Product A,19.99,true
-     *
-     * Output Document:
-     * {id:"123", name:"Product A", price:"19.99", instock:"true"}
-     * }
- * - * @param csv CSV string containing document data (first row must be headers) - * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if CSV parsing fails, input validation fails, or the - * structure is invalid - * @see SolrInputDocument - * @see FieldNameSanitizer#sanitizeFieldName(String) - */ - public List create(String csv) throws DocumentProcessingException { - if (csv.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) { - throw new DocumentProcessingException( - "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes"); - } + /** + * Creates a list of schema-less SolrInputDocument objects from a CSV string. + * + *

+ * This method implements a flexible document conversion strategy that allows + * Solr to automatically detect field types without requiring predefined schema + * configuration. It processes CSV data by using the first row as field headers + * and converting each subsequent row into a document. + * + *

+ * Schema-less Benefits: + * + *

    + *
  • Flexibility: No need to predefine field types in schema + *
  • Rapid Prototyping: Quick iteration on document + * structures + *
  • Type Detection: Solr automatically infers optimal field + * types + *
  • Dynamic Fields: Support for varying document structures + *
+ * + *

+ * CSV Processing Rules: + * + *

    + *
  • Header Row: First row defines field names, automatically + * sanitized + *
  • Empty Values: Ignored and not indexed + *
  • Type Detection: Solr handles numeric, boolean, and + * string types automatically + *
  • Field Sanitization: Column names cleaned for Solr + * compatibility + *
+ * + *

+ * Field Name Sanitization: + * + *

+ * Field names are automatically sanitized to ensure Solr compatibility by + * removing special characters and converting to lowercase with underscore + * separators. + * + *

+ * Example Transformation: + * + *

{@code
+	 * Input CSV:
+	 * id,name,price,inStock
+	 * 123,Product A,19.99,true
+	 *
+	 * Output Document:
+	 * {id:"123", name:"Product A", price:"19.99", instock:"true"}
+	 * }
+ * + * @param csv + * CSV string containing document data (first row must be headers) + * @return list of SolrInputDocument objects ready for indexing + * @throws DocumentProcessingException + * if CSV parsing fails, input validation fails, or the structure is + * invalid + * @see SolrInputDocument + * @see FieldNameSanitizer#sanitizeFieldName(String) + */ + public List create(String csv) throws DocumentProcessingException { + if (csv.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) { + throw new DocumentProcessingException( + "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes"); + } - List documents = new ArrayList<>(); + List documents = new ArrayList<>(); - try (CSVParser parser = - new CSVParser( - new StringReader(csv), - CSVFormat.Builder.create().setHeader().setTrim(true).build())) { - List headers = new ArrayList<>(parser.getHeaderNames()); - headers.replaceAll(FieldNameSanitizer::sanitizeFieldName); + try (CSVParser parser = new CSVParser(new StringReader(csv), + CSVFormat.Builder.create().setHeader().setTrim(true).build())) { + List headers = new ArrayList<>(parser.getHeaderNames()); + headers.replaceAll(FieldNameSanitizer::sanitizeFieldName); - for (CSVRecord csvRecord : parser) { - if (csvRecord.size() == 0) { - continue; // Skip empty lines - } + for (CSVRecord csvRecord : parser) { + if (csvRecord.size() == 0) { + continue; // Skip empty lines + } - SolrInputDocument doc = new SolrInputDocument(); + SolrInputDocument doc = new SolrInputDocument(); - for (int i = 0; i < headers.size() && i < csvRecord.size(); i++) { - String value = csvRecord.get(i); - if (!value.isEmpty()) { - doc.addField(headers.get(i), value); - } - } + for (int i = 0; i < headers.size() && i < csvRecord.size(); i++) { + String value = csvRecord.get(i); + if (!value.isEmpty()) { + doc.addField(headers.get(i), value); + } + } - documents.add(doc); - } - } catch (IOException e) { - throw new DocumentProcessingException("Failed to parse CSV document", e); - } + documents.add(doc); + } + } catch (IOException e) { + throw new DocumentProcessingException("Failed to parse CSV document", e); + } - return documents; - } + return documents; + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java index 6d0c22f..45849e9 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java @@ -19,51 +19,62 @@ /** * Exception thrown when document processing operations fail. * - *

This exception provides a unified error handling mechanism for all document creator - * implementations, wrapping various underlying exceptions while preserving the original error - * context and stack trace information. + *

+ * This exception provides a unified error handling mechanism for all document + * creator implementations, wrapping various underlying exceptions while + * preserving the original error context and stack trace information. * - *

Common scenarios where this exception is thrown: + *

+ * Common scenarios where this exception is thrown: * *

    - *
  • Invalid document format or structure - *
  • Document parsing errors (JSON, XML, CSV) - *
  • Input validation failures - *
  • Resource access or I/O errors during processing + *
  • Invalid document format or structure + *
  • Document parsing errors (JSON, XML, CSV) + *
  • Input validation failures + *
  • Resource access or I/O errors during processing *
*/ public class DocumentProcessingException extends RuntimeException { - /** - * Constructs a new DocumentProcessingException with the specified detail message. - * - * @param message the detail message explaining the error - */ - public DocumentProcessingException(String message) { - super(message); - } + /** + * Constructs a new DocumentProcessingException with the specified detail + * message. + * + * @param message + * the detail message explaining the error + */ + public DocumentProcessingException(String message) { + super(message); + } - /** - * Constructs a new DocumentProcessingException with the specified detail message and cause. - * - *

This constructor is particularly useful for wrapping underlying exceptions while providing - * additional context about the document processing failure. - * - * @param message the detail message explaining the error - * @param cause the cause of this exception (which is saved for later retrieval) - */ - public DocumentProcessingException(String message, Throwable cause) { - super(message, cause); - } + /** + * Constructs a new DocumentProcessingException with the specified detail + * message and cause. + * + *

+ * This constructor is particularly useful for wrapping underlying exceptions + * while providing additional context about the document processing failure. + * + * @param message + * the detail message explaining the error + * @param cause + * the cause of this exception (which is saved for later retrieval) + */ + public DocumentProcessingException(String message, Throwable cause) { + super(message, cause); + } - /** - * Constructs a new DocumentProcessingException with the specified cause. - * - *

The detail message is automatically derived from the cause's toString() method. - * - * @param cause the cause of this exception (which is saved for later retrieval) - */ - public DocumentProcessingException(Throwable cause) { - super(cause); - } + /** + * Constructs a new DocumentProcessingException with the specified cause. + * + *

+ * The detail message is automatically derived from the cause's toString() + * method. + * + * @param cause + * the cause of this exception (which is saved for later retrieval) + */ + public DocumentProcessingException(Throwable cause) { + super(cause); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java index 9879d8c..fb175a5 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java @@ -19,93 +19,99 @@ import java.util.regex.Pattern; /** - * Utility class for sanitizing field names to ensure compatibility with Solr's field naming - * requirements. + * Utility class for sanitizing field names to ensure compatibility with Solr's + * field naming requirements. * - *

This class provides shared regex patterns and sanitization logic that can be used across all - * document creators to ensure consistent field name handling. + *

+ * This class provides shared regex patterns and sanitization logic that can be + * used across all document creators to ensure consistent field name handling. * - *

Solr has specific requirements for field names that must be met to ensure proper indexing and - * searching functionality. This utility transforms arbitrary field names into Solr-compliant - * identifiers. + *

+ * Solr has specific requirements for field names that must be met to ensure + * proper indexing and searching functionality. This utility transforms + * arbitrary field names into Solr-compliant identifiers. */ public final class FieldNameSanitizer { - /** - * Pattern to match invalid characters in field names. Matches any character that is not - * alphanumeric or underscore. - */ - private static final Pattern INVALID_CHARACTERS_PATTERN = Pattern.compile("[\\W]"); + /** + * Pattern to match invalid characters in field names. Matches any character + * that is not alphanumeric or underscore. + */ + private static final Pattern INVALID_CHARACTERS_PATTERN = Pattern.compile("[\\W]"); - /** - * Pattern to match leading and trailing underscores. Uses explicit grouping to make operator - * precedence clear. - */ - private static final Pattern LEADING_TRAILING_UNDERSCORES_PATTERN = - Pattern.compile("(^_+)|(_+$)"); + /** + * Pattern to match leading and trailing underscores. Uses explicit grouping to + * make operator precedence clear. + */ + private static final Pattern LEADING_TRAILING_UNDERSCORES_PATTERN = Pattern.compile("(^_+)|(_+$)"); - /** - * Pattern to match multiple consecutive underscores. Matches two or more consecutive - * underscores to collapse them into one. - */ - private static final Pattern MULTIPLE_UNDERSCORES_PATTERN = Pattern.compile("_{2,}"); + /** + * Pattern to match multiple consecutive underscores. Matches two or more + * consecutive underscores to collapse them into one. + */ + private static final Pattern MULTIPLE_UNDERSCORES_PATTERN = Pattern.compile("_{2,}"); - // Private constructor to prevent instantiation - private FieldNameSanitizer() { - throw new UnsupportedOperationException("Utility class cannot be instantiated"); - } + // Private constructor to prevent instantiation + private FieldNameSanitizer() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } - /** - * Sanitizes field names to ensure they are compatible with Solr's field naming requirements. - * - *

Sanitization Rules: - * - *

    - *
  • Case Conversion: All characters converted to lowercase - *
  • Character Replacement: Non-alphanumeric characters replaced with - * underscores - *
  • Edge Trimming: Leading and trailing underscores removed - *
  • Duplicate Compression: Multiple consecutive underscores collapsed to - * single - *
  • Numeric Prefix: Field names starting with numbers get "field_" prefix - *
- * - *

Example Transformations: - * - *

    - *
  • "User-Name" → "user_name" - *
  • "product.price" → "product_price" - *
  • "__field__name__" → "field_name" - *
  • "Field123@Test" → "field123_test" - *
  • "123field" → "field_123field" - *
- * - * @param fieldName the original field name to sanitize - * @return sanitized field name compatible with Solr requirements, or "field" if input is - * null/empty - * @see
Solr - * Field Guide - */ - public static String sanitizeFieldName(String fieldName) { + /** + * Sanitizes field names to ensure they are compatible with Solr's field naming + * requirements. + * + *

+ * Sanitization Rules: + * + *

    + *
  • Case Conversion: All characters converted to lowercase + *
  • Character Replacement: Non-alphanumeric characters + * replaced with underscores + *
  • Edge Trimming: Leading and trailing underscores removed + *
  • Duplicate Compression: Multiple consecutive underscores + * collapsed to single + *
  • Numeric Prefix: Field names starting with numbers get + * "field_" prefix + *
+ * + *

+ * Example Transformations: + * + *

    + *
  • "User-Name" → "user_name" + *
  • "product.price" → "product_price" + *
  • "__field__name__" → "field_name" + *
  • "Field123@Test" → "field123_test" + *
  • "123field" → "field_123field" + *
+ * + * @param fieldName + * the original field name to sanitize + * @return sanitized field name compatible with Solr requirements, or "field" if + * input is null/empty + * @see Solr + * Field Guide + */ + public static String sanitizeFieldName(String fieldName) { - // Convert to lowercase and replace invalid characters with underscores - String sanitized = - INVALID_CHARACTERS_PATTERN.matcher(fieldName.toLowerCase()).replaceAll("_"); + // Convert to lowercase and replace invalid characters with underscores + String sanitized = INVALID_CHARACTERS_PATTERN.matcher(fieldName.toLowerCase()).replaceAll("_"); - // Remove leading/trailing underscores and collapse multiple underscores - sanitized = LEADING_TRAILING_UNDERSCORES_PATTERN.matcher(sanitized).replaceAll(""); - sanitized = MULTIPLE_UNDERSCORES_PATTERN.matcher(sanitized).replaceAll("_"); + // Remove leading/trailing underscores and collapse multiple underscores + sanitized = LEADING_TRAILING_UNDERSCORES_PATTERN.matcher(sanitized).replaceAll(""); + sanitized = MULTIPLE_UNDERSCORES_PATTERN.matcher(sanitized).replaceAll("_"); - // If the result is empty after sanitization, provide a default name - if (sanitized.isEmpty()) { - return "field"; - } + // If the result is empty after sanitization, provide a default name + if (sanitized.isEmpty()) { + return "field"; + } - // Ensure the field name doesn't start with a number (Solr requirement) - if (Character.isDigit(sanitized.charAt(0))) { - sanitized = "field_" + sanitized; - } + // Ensure the field name doesn't start with a number (Solr requirement) + if (Character.isDigit(sanitized.charAt(0))) { + sanitized = "field_" + sanitized; + } - return sanitized; - } + return sanitized; + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java index 5c9e595..4a6ab97 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java @@ -23,22 +23,29 @@ import org.springframework.stereotype.Service; /** - * Spring Service responsible for creating SolrInputDocument objects from various data formats. + * Spring Service responsible for creating SolrInputDocument objects from + * various data formats. * - *

This service handles the conversion of JSON, CSV, and XML documents into Solr-compatible - * format using a schema-less approach where Solr automatically detects field types, eliminating the - * need for predefined schema configuration. + *

+ * This service handles the conversion of JSON, CSV, and XML documents into + * Solr-compatible format using a schema-less approach where Solr automatically + * detects field types, eliminating the need for predefined schema + * configuration. * - *

Core Features: + *

+ * Core Features: * *

    - *
  • Schema-less Document Creation: Automatic field type detection by Solr - *
  • JSON Processing: Support for complex nested JSON documents - *
  • CSV Processing: Support for comma-separated value files with headers - *
  • XML Processing: Support for XML documents with element flattening and - * attribute handling - *
  • Field Sanitization: Automatic cleanup of field names for Solr - * compatibility + *
  • Schema-less Document Creation: Automatic field type + * detection by Solr + *
  • JSON Processing: Support for complex nested JSON + * documents + *
  • CSV Processing: Support for comma-separated value files + * with headers + *
  • XML Processing: Support for XML documents with element + * flattening and attribute handling + *
  • Field Sanitization: Automatic cleanup of field names for + * Solr compatibility *
* * @version 0.0.1 @@ -49,81 +56,84 @@ @Service public class IndexingDocumentCreator { - private static final int MAX_XML_SIZE_BYTES = 10 * 1024 * 1024; // 10MB limit + private static final int MAX_XML_SIZE_BYTES = 10 * 1024 * 1024; // 10MB limit - private final XmlDocumentCreator xmlDocumentCreator; + private final XmlDocumentCreator xmlDocumentCreator; - private final CsvDocumentCreator csvDocumentCreator; + private final CsvDocumentCreator csvDocumentCreator; - private final JsonDocumentCreator jsonDocumentCreator; + private final JsonDocumentCreator jsonDocumentCreator; - public IndexingDocumentCreator( - XmlDocumentCreator xmlDocumentCreator, - CsvDocumentCreator csvDocumentCreator, - JsonDocumentCreator jsonDocumentCreator) { - this.xmlDocumentCreator = xmlDocumentCreator; - this.csvDocumentCreator = csvDocumentCreator; - this.jsonDocumentCreator = jsonDocumentCreator; - } + public IndexingDocumentCreator(XmlDocumentCreator xmlDocumentCreator, CsvDocumentCreator csvDocumentCreator, + JsonDocumentCreator jsonDocumentCreator) { + this.xmlDocumentCreator = xmlDocumentCreator; + this.csvDocumentCreator = csvDocumentCreator; + this.jsonDocumentCreator = jsonDocumentCreator; + } - /** - * Creates a list of schema-less SolrInputDocument objects from a JSON string. - * - *

This method delegates JSON processing to the JsonDocumentProcessor utility class. - * - * @param json JSON string containing document data (must be an array) - * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if JSON parsing fails or the structure is invalid - * @see JsonDocumentCreator - */ - public List createSchemalessDocumentsFromJson(String json) - throws DocumentProcessingException { - return jsonDocumentCreator.create(json); - } + /** + * Creates a list of schema-less SolrInputDocument objects from a JSON string. + * + *

+ * This method delegates JSON processing to the JsonDocumentProcessor utility + * class. + * + * @param json + * JSON string containing document data (must be an array) + * @return list of SolrInputDocument objects ready for indexing + * @throws DocumentProcessingException + * if JSON parsing fails or the structure is invalid + * @see JsonDocumentCreator + */ + public List createSchemalessDocumentsFromJson(String json) throws DocumentProcessingException { + return jsonDocumentCreator.create(json); + } - /** - * Creates a list of schema-less SolrInputDocument objects from a CSV string. - * - *

This method delegates CSV processing to the CsvDocumentProcessor utility class. - * - * @param csv CSV string containing document data (first row must be headers) - * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if CSV parsing fails or the structure is invalid - * @see CsvDocumentCreator - */ - public List createSchemalessDocumentsFromCsv(String csv) - throws DocumentProcessingException { - return csvDocumentCreator.create(csv); - } + /** + * Creates a list of schema-less SolrInputDocument objects from a CSV string. + * + *

+ * This method delegates CSV processing to the CsvDocumentProcessor utility + * class. + * + * @param csv + * CSV string containing document data (first row must be headers) + * @return list of SolrInputDocument objects ready for indexing + * @throws DocumentProcessingException + * if CSV parsing fails or the structure is invalid + * @see CsvDocumentCreator + */ + public List createSchemalessDocumentsFromCsv(String csv) throws DocumentProcessingException { + return csvDocumentCreator.create(csv); + } - /** - * Creates a list of schema-less SolrInputDocument objects from an XML string. - * - *

This method delegates XML processing to the XmlDocumentProcessor utility class. - * - * @param xml XML string containing document data - * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if XML parser configuration fails - * @see XmlDocumentCreator - */ - public List createSchemalessDocumentsFromXml(String xml) - throws DocumentProcessingException { + /** + * Creates a list of schema-less SolrInputDocument objects from an XML string. + * + *

+ * This method delegates XML processing to the XmlDocumentProcessor utility + * class. + * + * @param xml + * XML string containing document data + * @return list of SolrInputDocument objects ready for indexing + * @throws DocumentProcessingException + * if XML parser configuration fails + * @see XmlDocumentCreator + */ + public List createSchemalessDocumentsFromXml(String xml) throws DocumentProcessingException { - // Input validation - if (xml == null || xml.trim().isEmpty()) { - throw new IllegalArgumentException("XML input cannot be null or empty"); - } + // Input validation + if (xml == null || xml.trim().isEmpty()) { + throw new IllegalArgumentException("XML input cannot be null or empty"); + } - byte[] xmlBytes = xml.getBytes(StandardCharsets.UTF_8); - if (xmlBytes.length > MAX_XML_SIZE_BYTES) { - throw new IllegalArgumentException( - "XML document too large: " - + xmlBytes.length - + " bytes (max: " - + MAX_XML_SIZE_BYTES - + ")"); - } + byte[] xmlBytes = xml.getBytes(StandardCharsets.UTF_8); + if (xmlBytes.length > MAX_XML_SIZE_BYTES) { + throw new IllegalArgumentException( + "XML document too large: " + xmlBytes.length + " bytes (max: " + MAX_XML_SIZE_BYTES + ")"); + } - return xmlDocumentCreator.create(xml); - } + return xmlDocumentCreator.create(xml); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java index 266ebd7..1a8a149 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java @@ -28,192 +28,228 @@ import org.springframework.stereotype.Component; /** - * Utility class for processing JSON documents and converting them to SolrInputDocument objects. + * Utility class for processing JSON documents and converting them to + * SolrInputDocument objects. * - *

This class handles the conversion of JSON documents into Solr-compatible format using a - * schema-less approach where Solr automatically detects field types. + *

+ * This class handles the conversion of JSON documents into Solr-compatible + * format using a schema-less approach where Solr automatically detects field + * types. */ @Component public class JsonDocumentCreator implements SolrDocumentCreator { - private static final int MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024; + private static final int MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024; - /** - * Creates a list of schema-less SolrInputDocument objects from a JSON string. - * - *

This method implements a flexible document conversion strategy that allows Solr to - * automatically detect field types without requiring predefined schema configuration. It - * processes complex JSON structures by flattening nested objects and handling arrays - * appropriately for Solr's multi-valued field support. - * - *

Schema-less Benefits: - * - *

    - *
  • Flexibility: No need to predefine field types in schema - *
  • Rapid Prototyping: Quick iteration on document structures - *
  • Type Detection: Solr automatically infers optimal field types - *
  • Dynamic Fields: Support for varying document structures - *
- * - *

JSON Processing Rules: - * - *

    - *
  • Nested Objects: Flattened using underscore notation (e.g., "user.name" - * → "user_name") - *
  • Arrays: Non-object arrays converted to multi-valued fields - *
  • Null Values: Ignored and not indexed - *
  • Object Arrays: Skipped to avoid complex nested structures - *
- * - *

Field Name Sanitization: - * - *

Field names are automatically sanitized to ensure Solr compatibility by removing special - * characters and converting to lowercase with underscore separators. - * - *

Example Transformations: - * - *

{@code
-     * Input:  {"user":{"name":"John","age":30},"tags":["tech","java"]}
-     * Output: {user_name:"John", user_age:30, tags:["tech","java"]}
-     * }
- * - * @param json JSON string containing document data (must be an array) - * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if JSON parsing fails, input validation fails, or the - * structure is invalid - * @see SolrInputDocument - * @see #addAllFieldsFlat(SolrInputDocument, JsonNode, String) - * @see FieldNameSanitizer#sanitizeFieldName(String) - */ - public List create(String json) throws DocumentProcessingException { - if (json.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) { - throw new DocumentProcessingException( - "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes"); - } + /** + * Creates a list of schema-less SolrInputDocument objects from a JSON string. + * + *

+ * This method implements a flexible document conversion strategy that allows + * Solr to automatically detect field types without requiring predefined schema + * configuration. It processes complex JSON structures by flattening nested + * objects and handling arrays appropriately for Solr's multi-valued field + * support. + * + *

+ * Schema-less Benefits: + * + *

    + *
  • Flexibility: No need to predefine field types in schema + *
  • Rapid Prototyping: Quick iteration on document + * structures + *
  • Type Detection: Solr automatically infers optimal field + * types + *
  • Dynamic Fields: Support for varying document structures + *
+ * + *

+ * JSON Processing Rules: + * + *

    + *
  • Nested Objects: Flattened using underscore notation + * (e.g., "user.name" → "user_name") + *
  • Arrays: Non-object arrays converted to multi-valued + * fields + *
  • Null Values: Ignored and not indexed + *
  • Object Arrays: Skipped to avoid complex nested + * structures + *
+ * + *

+ * Field Name Sanitization: + * + *

+ * Field names are automatically sanitized to ensure Solr compatibility by + * removing special characters and converting to lowercase with underscore + * separators. + * + *

+ * Example Transformations: + * + *

{@code
+	 * Input:  {"user":{"name":"John","age":30},"tags":["tech","java"]}
+	 * Output: {user_name:"John", user_age:30, tags:["tech","java"]}
+	 * }
+ * + * @param json + * JSON string containing document data (must be an array) + * @return list of SolrInputDocument objects ready for indexing + * @throws DocumentProcessingException + * if JSON parsing fails, input validation fails, or the structure + * is invalid + * @see SolrInputDocument + * @see #addAllFieldsFlat(SolrInputDocument, JsonNode, String) + * @see FieldNameSanitizer#sanitizeFieldName(String) + */ + public List create(String json) throws DocumentProcessingException { + if (json.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) { + throw new DocumentProcessingException( + "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes"); + } - List documents = new ArrayList<>(); + List documents = new ArrayList<>(); - try { - ObjectMapper mapper = new ObjectMapper(); - JsonNode rootNode = mapper.readTree(json); + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(json); - if (rootNode.isArray()) { - for (JsonNode item : rootNode) { - SolrInputDocument doc = new SolrInputDocument(); + if (rootNode.isArray()) { + for (JsonNode item : rootNode) { + SolrInputDocument doc = new SolrInputDocument(); - // Add all fields without type suffixes - let Solr figure it out - addAllFieldsFlat(doc, item, ""); - documents.add(doc); - } - } - } catch (IOException e) { - throw new DocumentProcessingException("Failed to parse JSON document", e); - } + // Add all fields without type suffixes - let Solr figure it out + addAllFieldsFlat(doc, item, ""); + documents.add(doc); + } + } + } catch (IOException e) { + throw new DocumentProcessingException("Failed to parse JSON document", e); + } - return documents; - } + return documents; + } - /** - * Recursively flattens JSON nodes and adds them as fields to a SolrInputDocument. - * - *

This method implements the core logic for converting nested JSON structures into flat - * field names that Solr can efficiently index and search. It handles various JSON node types - * appropriately while maintaining data integrity. - * - *

Processing Logic: - * - *

    - *
  • Null Values: Skipped to avoid indexing empty fields - *
  • Arrays: Non-object items converted to multi-valued fields - *
  • Objects: Recursively flattened with prefix concatenation - *
  • Primitives: Directly added with appropriate type conversion - *
- * - * @param doc the SolrInputDocument to add fields to - * @param node the JSON node to process - * @param prefix current field name prefix for nested object flattening - * @see #convertJsonValue(JsonNode) - * @see FieldNameSanitizer#sanitizeFieldName(String) - */ - private void addAllFieldsFlat(SolrInputDocument doc, JsonNode node, String prefix) { - Set> fields = node.properties(); - fields.forEach( - field -> - processFieldValue( - doc, - field.getValue(), - FieldNameSanitizer.sanitizeFieldName(prefix + field.getKey()))); - } + /** + * Recursively flattens JSON nodes and adds them as fields to a + * SolrInputDocument. + * + *

+ * This method implements the core logic for converting nested JSON structures + * into flat field names that Solr can efficiently index and search. It handles + * various JSON node types appropriately while maintaining data integrity. + * + *

+ * Processing Logic: + * + *

    + *
  • Null Values: Skipped to avoid indexing empty fields + *
  • Arrays: Non-object items converted to multi-valued + * fields + *
  • Objects: Recursively flattened with prefix concatenation + *
  • Primitives: Directly added with appropriate type + * conversion + *
+ * + * @param doc + * the SolrInputDocument to add fields to + * @param node + * the JSON node to process + * @param prefix + * current field name prefix for nested object flattening + * @see #convertJsonValue(JsonNode) + * @see FieldNameSanitizer#sanitizeFieldName(String) + */ + private void addAllFieldsFlat(SolrInputDocument doc, JsonNode node, String prefix) { + Set> fields = node.properties(); + fields.forEach(field -> processFieldValue(doc, field.getValue(), + FieldNameSanitizer.sanitizeFieldName(prefix + field.getKey()))); + } - /** - * Processes the provided field value and adds it to the given SolrInputDocument. Handles cases - * where the field value is an array, object, or a simple value. - * - * @param doc the SolrInputDocument to which the field value will be added - * @param value the JsonNode representing the field value to be processed - * @param fieldName the name of the field to be added to the SolrInputDocument - */ - private void processFieldValue(SolrInputDocument doc, JsonNode value, String fieldName) { - if (value.isNull()) { - return; - } + /** + * Processes the provided field value and adds it to the given + * SolrInputDocument. Handles cases where the field value is an array, object, + * or a simple value. + * + * @param doc + * the SolrInputDocument to which the field value will be added + * @param value + * the JsonNode representing the field value to be processed + * @param fieldName + * the name of the field to be added to the SolrInputDocument + */ + private void processFieldValue(SolrInputDocument doc, JsonNode value, String fieldName) { + if (value.isNull()) { + return; + } - if (value.isArray()) { - processArrayField(doc, value, fieldName); - } else if (value.isObject()) { - addAllFieldsFlat(doc, value, fieldName + "_"); - } else { - doc.addField(fieldName, convertJsonValue(value)); - } - } + if (value.isArray()) { + processArrayField(doc, value, fieldName); + } else if (value.isObject()) { + addAllFieldsFlat(doc, value, fieldName + "_"); + } else { + doc.addField(fieldName, convertJsonValue(value)); + } + } - /** - * Processes a JSON array field and adds its non-object elements to the specified field in the - * given SolrInputDocument. - * - * @param doc the SolrInputDocument to which the processed field will be added - * @param arrayValue the JSON array node to process - * @param fieldName the name of the field in the SolrInputDocument to which the array values - * will be added - */ - private void processArrayField(SolrInputDocument doc, JsonNode arrayValue, String fieldName) { - List values = new ArrayList<>(); - for (JsonNode item : arrayValue) { - if (!item.isObject()) { - values.add(convertJsonValue(item)); - } - } - if (!values.isEmpty()) { - doc.addField(fieldName, values); - } - } + /** + * Processes a JSON array field and adds its non-object elements to the + * specified field in the given SolrInputDocument. + * + * @param doc + * the SolrInputDocument to which the processed field will be added + * @param arrayValue + * the JSON array node to process + * @param fieldName + * the name of the field in the SolrInputDocument to which the array + * values will be added + */ + private void processArrayField(SolrInputDocument doc, JsonNode arrayValue, String fieldName) { + List values = new ArrayList<>(); + for (JsonNode item : arrayValue) { + if (!item.isObject()) { + values.add(convertJsonValue(item)); + } + } + if (!values.isEmpty()) { + doc.addField(fieldName, values); + } + } - /** - * Converts a JsonNode value to the appropriate Java object type for Solr indexing. - * - *

This method provides type-aware conversion of JSON values to their corresponding Java - * types, ensuring that Solr receives properly typed data for optimal field type detection and - * indexing performance. - * - *

Supported Type Conversions: - * - *

    - *
  • Boolean: JSON boolean → Java Boolean - *
  • Integer: JSON number (int range) → Java Integer - *
  • Long: JSON number (long range) → Java Long - *
  • Double: JSON number (decimal) → Java Double - *
  • String: All other values → Java String - *
- * - * @param value the JsonNode value to convert - * @return the converted Java object with appropriate type - * @see JsonNode - */ - private Object convertJsonValue(JsonNode value) { - if (value.isBoolean()) return value.asBoolean(); - if (value.isLong()) return value.asLong(); - if (value.isDouble()) return value.asDouble(); - if (value.isInt()) return value.asInt(); - return value.asText(); - } + /** + * Converts a JsonNode value to the appropriate Java object type for Solr + * indexing. + * + *

+ * This method provides type-aware conversion of JSON values to their + * corresponding Java types, ensuring that Solr receives properly typed data for + * optimal field type detection and indexing performance. + * + *

+ * Supported Type Conversions: + * + *

    + *
  • Boolean: JSON boolean → Java Boolean + *
  • Integer: JSON number (int range) → Java Integer + *
  • Long: JSON number (long range) → Java Long + *
  • Double: JSON number (decimal) → Java Double + *
  • String: All other values → Java String + *
+ * + * @param value + * the JsonNode value to convert + * @return the converted Java object with appropriate type + * @see JsonNode + */ + private Object convertJsonValue(JsonNode value) { + if (value.isBoolean()) + return value.asBoolean(); + if (value.isLong()) + return value.asLong(); + if (value.isDouble()) + return value.asDouble(); + if (value.isInt()) + return value.asInt(); + return value.asText(); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java index 35ef4e5..5e4df0c 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java @@ -20,33 +20,40 @@ import org.apache.solr.common.SolrInputDocument; /** - * Interface defining the contract for creating SolrInputDocument objects from various data formats. + * Interface defining the contract for creating SolrInputDocument objects from + * various data formats. * - *

This interface provides a unified abstraction for converting different document formats (JSON, - * CSV, XML, etc.) into Solr-compatible SolrInputDocument objects. Implementations handle - * format-specific parsing and field sanitization to ensure proper Solr indexing. + *

+ * This interface provides a unified abstraction for converting different + * document formats (JSON, CSV, XML, etc.) into Solr-compatible + * SolrInputDocument objects. Implementations handle format-specific parsing and + * field sanitization to ensure proper Solr indexing. * - *

Design Principles: + *

+ * Design Principles: * *

    - *
  • Format Agnostic: Common interface for all document types - *
  • Schema-less Processing: Supports dynamic field creation without predefined - * schema - *
  • Error Handling: Consistent exception handling across implementations - *
  • Field Sanitization: Automatic cleanup of field names for Solr - * compatibility + *
  • Format Agnostic: Common interface for all document types + *
  • Schema-less Processing: Supports dynamic field creation + * without predefined schema + *
  • Error Handling: Consistent exception handling across + * implementations + *
  • Field Sanitization: Automatic cleanup of field names for + * Solr compatibility *
* - *

Implementation Guidelines: + *

+ * Implementation Guidelines: * *

    - *
  • Handle null or empty input gracefully - *
  • Sanitize field names using {@link FieldNameSanitizer} - *
  • Preserve original data types where possible - *
  • Throw {@link DocumentProcessingException} for processing errors + *
  • Handle null or empty input gracefully + *
  • Sanitize field names using {@link FieldNameSanitizer} + *
  • Preserve original data types where possible + *
  • Throw {@link DocumentProcessingException} for processing errors *
* - *

Usage Example: + *

+ * Usage Example: * *

{@code
  * SolrDocumentCreator creator = new JsonDocumentCreator();
@@ -62,39 +69,48 @@
  */
 public interface SolrDocumentCreator {
 
-    /**
-     * Creates a list of SolrInputDocument objects from the provided content string.
-     *
-     * 

This method parses the input content according to the specific format handled by the - * implementing class (JSON, CSV, XML, etc.) and converts it into a list of SolrInputDocument - * objects ready for indexing. - * - *

Processing Behavior: - * - *

    - *
  • Field Sanitization: All field names are sanitized for Solr - * compatibility - *
  • Type Preservation: Original data types are maintained where possible - *
  • Multiple Documents: Single content string may produce multiple - * documents - *
  • Error Handling: Invalid content results in DocumentProcessingException - *
- * - *

Input Validation: - * - *

    - *
  • Null input should be handled gracefully (implementation-dependent) - *
  • Empty input should return empty list - *
  • Malformed content should throw DocumentProcessingException - *
- * - * @param content the content string to be parsed and converted to SolrInputDocument objects. - * The format depends on the implementing class (JSON array, CSV data, XML, etc.) - * @return a list of SolrInputDocument objects created from the parsed content. Returns empty - * list if content is empty or contains no valid documents - * @throws DocumentProcessingException if the content cannot be parsed or converted due to - * format errors, invalid structure, or processing failures - * @throws IllegalArgumentException if content is null (implementation-dependent) - */ - List create(String content) throws DocumentProcessingException; + /** + * Creates a list of SolrInputDocument objects from the provided content string. + * + *

+ * This method parses the input content according to the specific format handled + * by the implementing class (JSON, CSV, XML, etc.) and converts it into a list + * of SolrInputDocument objects ready for indexing. + * + *

+ * Processing Behavior: + * + *

    + *
  • Field Sanitization: All field names are sanitized for + * Solr compatibility + *
  • Type Preservation: Original data types are maintained + * where possible + *
  • Multiple Documents: Single content string may produce + * multiple documents + *
  • Error Handling: Invalid content results in + * DocumentProcessingException + *
+ * + *

+ * Input Validation: + * + *

    + *
  • Null input should be handled gracefully (implementation-dependent) + *
  • Empty input should return empty list + *
  • Malformed content should throw DocumentProcessingException + *
+ * + * @param content + * the content string to be parsed and converted to SolrInputDocument + * objects. The format depends on the implementing class (JSON array, + * CSV data, XML, etc.) + * @return a list of SolrInputDocument objects created from the parsed content. + * Returns empty list if content is empty or contains no valid documents + * @throws DocumentProcessingException + * if the content cannot be parsed or converted due to format + * errors, invalid structure, or processing failures + * @throws IllegalArgumentException + * if content is null (implementation-dependent) + */ + List create(String content) throws DocumentProcessingException; } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java index 827d574..bf0158b 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java @@ -36,244 +36,248 @@ import org.xml.sax.SAXException; /** - * Utility class for processing XML documents and converting them to SolrInputDocument objects. + * Utility class for processing XML documents and converting them to + * SolrInputDocument objects. * - *

This class handles the conversion of XML documents into Solr-compatible format using a - * schema-less approach where Solr automatically detects field types. + *

+ * This class handles the conversion of XML documents into Solr-compatible + * format using a schema-less approach where Solr automatically detects field + * types. */ @Component public class XmlDocumentCreator implements SolrDocumentCreator { - /** - * Creates a list of SolrInputDocument objects from XML content. - * - *

This method parses the XML and creates documents based on the structure: - If the XML has - * multiple child elements with the same tag name (indicating repeated structures), each child - * element becomes a separate document - Otherwise, the entire XML structure is treated as a - * single document - * - *

This approach is flexible and doesn't rely on hardcoded element names, allowing it to work - * with any XML structure. - * - * @param xml the XML content to process - * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if XML parsing fails, parser configuration fails, or - * structural errors occur - */ - public List create(String xml) throws DocumentProcessingException { - try { - Element rootElement = parseXmlDocument(xml); - return processRootElement(rootElement); - } catch (ParserConfigurationException e) { - throw new DocumentProcessingException("Failed to configure XML parser", e); - } catch (SAXException e) { - throw new DocumentProcessingException( - "Failed to parse XML document: structural error", e); - } catch (IOException e) { - throw new DocumentProcessingException("Failed to read XML document", e); - } - } - - /** Parses XML string into a DOM Element. */ - private Element parseXmlDocument(String xml) - throws ParserConfigurationException, SAXException, IOException { - DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document doc = - builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - return doc.getDocumentElement(); - } - - /** Creates a secure DocumentBuilderFactory with XXE protection. */ - private DocumentBuilderFactory createSecureDocumentBuilderFactory() - throws ParserConfigurationException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - return factory; - } - - /** Processes the root element and determines document structure strategy. */ - private List processRootElement(Element rootElement) { - List childElements = extractChildElements(rootElement); - - if (shouldTreatChildrenAsDocuments(childElements)) { - return createDocumentsFromChildren(childElements); - } else { - return createSingleDocument(rootElement); - } - } - - /** Extracts child elements from the root element. */ - private List extractChildElements(Element rootElement) { - NodeList children = rootElement.getChildNodes(); - List childElements = new ArrayList<>(); - - for (int i = 0; i < children.getLength(); i++) { - if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { - childElements.add((Element) children.item(i)); - } - } - - return childElements; - } - - /** Determines if child elements should be treated as separate documents. */ - private boolean shouldTreatChildrenAsDocuments(List childElements) { - Map childElementCounts = new HashMap<>(); - - for (Element child : childElements) { - String tagName = child.getTagName(); - childElementCounts.put(tagName, childElementCounts.getOrDefault(tagName, 0) + 1); - } - - return childElementCounts.values().stream().anyMatch(count -> count > 1); - } - - /** Creates documents from child elements (multiple documents strategy). */ - private List createDocumentsFromChildren(List childElements) { - List documents = new ArrayList<>(); - - for (Element childElement : childElements) { - SolrInputDocument solrDoc = new SolrInputDocument(); - addXmlElementFields(solrDoc, childElement, ""); - if (!solrDoc.isEmpty()) { - documents.add(solrDoc); - } - } - - return documents; - } - - /** Creates a single document from the root element. */ - private List createSingleDocument(Element rootElement) { - List documents = new ArrayList<>(); - SolrInputDocument solrDoc = new SolrInputDocument(); - addXmlElementFields(solrDoc, rootElement, ""); - - if (!solrDoc.isEmpty()) { - documents.add(solrDoc); - } - - return documents; - } - - /** - * Recursively processes XML elements and adds them as fields to a SolrInputDocument. - * - *

This method implements the core logic for converting nested XML structures into flat field - * names that Solr can efficiently index and search. It handles both element content and - * attributes while maintaining data integrity. - * - *

Processing Logic: - * - *

    - *
  • Attributes: Converted to fields with "_attr" suffix - *
  • Text Content: Element text content indexed directly - *
  • Child Elements: Recursively processed with prefix concatenation - *
  • Empty Elements: Skipped to avoid indexing empty fields - *
  • Repeated Elements: Combined into multi-valued fields - *
- * - *

Field Naming Convention: - * - *

    - *
  • Nested elements: parent_child (e.g., author_name) - *
  • Attributes: elementname_attr (e.g., id_attr) - *
  • All field names are sanitized for Solr compatibility - *
- * - * @param doc the SolrInputDocument to add fields to - * @param element the XML element to process - * @param prefix current field name prefix for nested element flattening - * @see FieldNameSanitizer#sanitizeFieldName(String) - */ - private void addXmlElementFields(SolrInputDocument doc, Element element, String prefix) { - String elementName = FieldNameSanitizer.sanitizeFieldName(element.getTagName()); - String currentPrefix = prefix.isEmpty() ? elementName : prefix + "_" + elementName; - - processXmlAttributes(doc, element, prefix, currentPrefix); - - NodeList children = element.getChildNodes(); - boolean hasChildElements = hasChildElements(children); - - processXmlTextContent(doc, elementName, currentPrefix, prefix, hasChildElements, children); - processXmlChildElements(doc, children, currentPrefix); - } - - /** Processes XML element attributes and adds them as fields to the document. */ - private void processXmlAttributes( - SolrInputDocument doc, Element element, String prefix, String currentPrefix) { - if (!element.hasAttributes()) { - return; - } - - for (int i = 0; i < element.getAttributes().getLength(); i++) { - Node attr = element.getAttributes().item(i); - String attrName = FieldNameSanitizer.sanitizeFieldName(attr.getNodeName()) + "_attr"; - String fieldName = prefix.isEmpty() ? attrName : currentPrefix + "_" + attrName; - String attrValue = attr.getNodeValue(); - - if (attrValue != null && !attrValue.trim().isEmpty()) { - doc.addField(fieldName, attrValue.trim()); - } - } - } - - /** Checks if the node list contains any child elements. */ - private boolean hasChildElements(NodeList children) { - for (int i = 0; i < children.getLength(); i++) { - if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { - return true; - } - } - return false; - } - - /** Processes XML text content and adds it as a field to the document. */ - private void processXmlTextContent( - SolrInputDocument doc, - String elementName, - String currentPrefix, - String prefix, - boolean hasChildElements, - NodeList children) { - String textContent = extractTextContent(children); - if (!textContent.isEmpty()) { - String fieldName = prefix.isEmpty() ? elementName : currentPrefix; - doc.addField(fieldName, textContent); - } - } - - /** Extracts text content from child nodes. */ - private String extractTextContent(NodeList children) { - StringBuilder textContent = new StringBuilder(); - - for (int i = 0; i < children.getLength(); i++) { - Node child = children.item(i); - if (child.getNodeType() == Node.TEXT_NODE) { - String text = child.getNodeValue(); - if (text != null && !text.trim().isEmpty()) { - textContent.append(text.trim()).append(" "); - } - } - } - - return textContent.toString().trim(); - } - - /** Recursively processes XML child elements. */ - private void processXmlChildElements( - SolrInputDocument doc, NodeList children, String currentPrefix) { - for (int i = 0; i < children.getLength(); i++) { - Node child = children.item(i); - if (child.getNodeType() == Node.ELEMENT_NODE) { - addXmlElementFields(doc, (Element) child, currentPrefix); - } - } - } + /** + * Creates a list of SolrInputDocument objects from XML content. + * + *

+ * This method parses the XML and creates documents based on the structure: - If + * the XML has multiple child elements with the same tag name (indicating + * repeated structures), each child element becomes a separate document - + * Otherwise, the entire XML structure is treated as a single document + * + *

+ * This approach is flexible and doesn't rely on hardcoded element names, + * allowing it to work with any XML structure. + * + * @param xml + * the XML content to process + * @return list of SolrInputDocument objects ready for indexing + * @throws DocumentProcessingException + * if XML parsing fails, parser configuration fails, or structural + * errors occur + */ + public List create(String xml) throws DocumentProcessingException { + try { + Element rootElement = parseXmlDocument(xml); + return processRootElement(rootElement); + } catch (ParserConfigurationException e) { + throw new DocumentProcessingException("Failed to configure XML parser", e); + } catch (SAXException e) { + throw new DocumentProcessingException("Failed to parse XML document: structural error", e); + } catch (IOException e) { + throw new DocumentProcessingException("Failed to read XML document", e); + } + } + + /** Parses XML string into a DOM Element. */ + private Element parseXmlDocument(String xml) throws ParserConfigurationException, SAXException, IOException { + DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + return doc.getDocumentElement(); + } + + /** Creates a secure DocumentBuilderFactory with XXE protection. */ + private DocumentBuilderFactory createSecureDocumentBuilderFactory() throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory; + } + + /** Processes the root element and determines document structure strategy. */ + private List processRootElement(Element rootElement) { + List childElements = extractChildElements(rootElement); + + if (shouldTreatChildrenAsDocuments(childElements)) { + return createDocumentsFromChildren(childElements); + } else { + return createSingleDocument(rootElement); + } + } + + /** Extracts child elements from the root element. */ + private List extractChildElements(Element rootElement) { + NodeList children = rootElement.getChildNodes(); + List childElements = new ArrayList<>(); + + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + childElements.add((Element) children.item(i)); + } + } + + return childElements; + } + + /** Determines if child elements should be treated as separate documents. */ + private boolean shouldTreatChildrenAsDocuments(List childElements) { + Map childElementCounts = new HashMap<>(); + + for (Element child : childElements) { + String tagName = child.getTagName(); + childElementCounts.put(tagName, childElementCounts.getOrDefault(tagName, 0) + 1); + } + + return childElementCounts.values().stream().anyMatch(count -> count > 1); + } + + /** Creates documents from child elements (multiple documents strategy). */ + private List createDocumentsFromChildren(List childElements) { + List documents = new ArrayList<>(); + + for (Element childElement : childElements) { + SolrInputDocument solrDoc = new SolrInputDocument(); + addXmlElementFields(solrDoc, childElement, ""); + if (!solrDoc.isEmpty()) { + documents.add(solrDoc); + } + } + + return documents; + } + + /** Creates a single document from the root element. */ + private List createSingleDocument(Element rootElement) { + List documents = new ArrayList<>(); + SolrInputDocument solrDoc = new SolrInputDocument(); + addXmlElementFields(solrDoc, rootElement, ""); + + if (!solrDoc.isEmpty()) { + documents.add(solrDoc); + } + + return documents; + } + + /** + * Recursively processes XML elements and adds them as fields to a + * SolrInputDocument. + * + *

+ * This method implements the core logic for converting nested XML structures + * into flat field names that Solr can efficiently index and search. It handles + * both element content and attributes while maintaining data integrity. + * + *

+ * Processing Logic: + * + *

    + *
  • Attributes: Converted to fields with "_attr" suffix + *
  • Text Content: Element text content indexed directly + *
  • Child Elements: Recursively processed with prefix + * concatenation + *
  • Empty Elements: Skipped to avoid indexing empty fields + *
  • Repeated Elements: Combined into multi-valued fields + *
+ * + *

+ * Field Naming Convention: + * + *

    + *
  • Nested elements: parent_child (e.g., author_name) + *
  • Attributes: elementname_attr (e.g., id_attr) + *
  • All field names are sanitized for Solr compatibility + *
+ * + * @param doc + * the SolrInputDocument to add fields to + * @param element + * the XML element to process + * @param prefix + * current field name prefix for nested element flattening + * @see FieldNameSanitizer#sanitizeFieldName(String) + */ + private void addXmlElementFields(SolrInputDocument doc, Element element, String prefix) { + String elementName = FieldNameSanitizer.sanitizeFieldName(element.getTagName()); + String currentPrefix = prefix.isEmpty() ? elementName : prefix + "_" + elementName; + + processXmlAttributes(doc, element, prefix, currentPrefix); + + NodeList children = element.getChildNodes(); + boolean hasChildElements = hasChildElements(children); + + processXmlTextContent(doc, elementName, currentPrefix, prefix, hasChildElements, children); + processXmlChildElements(doc, children, currentPrefix); + } + + /** Processes XML element attributes and adds them as fields to the document. */ + private void processXmlAttributes(SolrInputDocument doc, Element element, String prefix, String currentPrefix) { + if (!element.hasAttributes()) { + return; + } + + for (int i = 0; i < element.getAttributes().getLength(); i++) { + Node attr = element.getAttributes().item(i); + String attrName = FieldNameSanitizer.sanitizeFieldName(attr.getNodeName()) + "_attr"; + String fieldName = prefix.isEmpty() ? attrName : currentPrefix + "_" + attrName; + String attrValue = attr.getNodeValue(); + + if (attrValue != null && !attrValue.trim().isEmpty()) { + doc.addField(fieldName, attrValue.trim()); + } + } + } + + /** Checks if the node list contains any child elements. */ + private boolean hasChildElements(NodeList children) { + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + return true; + } + } + return false; + } + + /** Processes XML text content and adds it as a field to the document. */ + private void processXmlTextContent(SolrInputDocument doc, String elementName, String currentPrefix, String prefix, + boolean hasChildElements, NodeList children) { + String textContent = extractTextContent(children); + if (!textContent.isEmpty()) { + String fieldName = prefix.isEmpty() ? elementName : currentPrefix; + doc.addField(fieldName, textContent); + } + } + + /** Extracts text content from child nodes. */ + private String extractTextContent(NodeList children) { + StringBuilder textContent = new StringBuilder(); + + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.TEXT_NODE) { + String text = child.getNodeValue(); + if (text != null && !text.trim().isEmpty()) { + textContent.append(text.trim()).append(" "); + } + } + } + + return textContent.toString().trim(); + } + + /** Recursively processes XML child elements. */ + private void processXmlChildElements(SolrInputDocument doc, NodeList children, String currentPrefix) { + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + addXmlElementFields(doc, (Element) child, currentPrefix); + } + } + } } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java index 5f42008..165b10b 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java @@ -41,53 +41,67 @@ import org.springframework.stereotype.Service; /** - * Spring Service providing comprehensive Solr collection management and monitoring capabilities for - * Model Context Protocol (MCP) clients. + * Spring Service providing comprehensive Solr collection management and + * monitoring capabilities for Model Context Protocol (MCP) clients. * - *

This service acts as the primary interface for collection-level operations in the Solr MCP - * Server, providing tools for collection discovery, metrics gathering, health monitoring, and - * performance analysis. It bridges the gap between MCP clients (like Claude Desktop) and Apache - * Solr through the SolrJ client library. + *

+ * This service acts as the primary interface for collection-level operations in + * the Solr MCP Server, providing tools for collection discovery, metrics + * gathering, health monitoring, and performance analysis. It bridges the gap + * between MCP clients (like Claude Desktop) and Apache Solr through the SolrJ + * client library. * - *

Core Capabilities: + *

+ * Core Capabilities: * *

    - *
  • Collection Discovery: Lists available collections/cores with automatic - * SolrCloud vs standalone detection - *
  • Performance Monitoring: Comprehensive metrics collection including index, - * query, cache, and handler statistics - *
  • Health Monitoring: Real-time health checks with availability and - * performance indicators - *
  • Shard-Aware Operations: Intelligent handling of SolrCloud shard names and - * collection name extraction + *
  • Collection Discovery: Lists available collections/cores + * with automatic SolrCloud vs standalone detection + *
  • Performance Monitoring: Comprehensive metrics collection + * including index, query, cache, and handler statistics + *
  • Health Monitoring: Real-time health checks with + * availability and performance indicators + *
  • Shard-Aware Operations: Intelligent handling of + * SolrCloud shard names and collection name extraction *
* - *

Implementation Details: + *

+ * Implementation Details: * - *

This class uses extensively documented constants for all API parameters, field names, and - * paths to ensure maintainability and reduce the risk of typos. All string literals have been - * replaced with well-named constants that are organized by category (API parameters, response - * parsing keys, handler paths, statistics fields, etc.). + *

+ * This class uses extensively documented constants for all API parameters, + * field names, and paths to ensure maintainability and reduce the risk of + * typos. All string literals have been replaced with well-named constants that + * are organized by category (API parameters, response parsing keys, handler + * paths, statistics fields, etc.). * - *

MCP Tool Integration: + *

+ * MCP Tool Integration: * - *

Methods annotated with {@code @McpTool} are automatically exposed as MCP tools that can be - * invoked by AI clients. These tools provide natural language interfaces to Solr operations. + *

+ * Methods annotated with {@code @McpTool} are automatically exposed as MCP + * tools that can be invoked by AI clients. These tools provide natural language + * interfaces to Solr operations. * - *

Supported Solr Deployments: + *

+ * Supported Solr Deployments: * *

    - *
  • SolrCloud: Distributed mode using Collections API - *
  • Standalone: Single-node mode using Core Admin API + *
  • SolrCloud: Distributed mode using Collections API + *
  • Standalone: Single-node mode using Core Admin API *
* - *

Error Handling: + *

+ * Error Handling: * - *

The service implements robust error handling with graceful degradation. Failed operations - * return null values rather than throwing exceptions (except where validation requires it), - * allowing partial metrics collection when some endpoints are unavailable. + *

+ * The service implements robust error handling with graceful degradation. + * Failed operations return null values rather than throwing exceptions (except + * where validation requires it), allowing partial metrics collection when some + * endpoints are unavailable. * - *

Example Usage: + *

+ * Example Usage: * *

{@code
  * // List all available collections
@@ -109,825 +123,874 @@
 @Service
 public class CollectionService {
 
-    // ========================================
-    // Constants for API Parameters and Paths
-    // ========================================
+	// ========================================
+	// Constants for API Parameters and Paths
+	// ========================================
 
-    /** Category parameter value for cache-related MBeans requests */
-    private static final String CACHE_CATEGORY = "CACHE";
+	/** Category parameter value for cache-related MBeans requests */
+	private static final String CACHE_CATEGORY = "CACHE";
 
-    /** Category parameter value for query handler MBeans requests */
-    private static final String QUERY_HANDLER_CATEGORY = "QUERYHANDLER";
+	/** Category parameter value for query handler MBeans requests */
+	private static final String QUERY_HANDLER_CATEGORY = "QUERYHANDLER";
 
-    /** Combined category parameter value for both query and update handler MBeans requests */
-    private static final String HANDLER_CATEGORIES = "QUERYHANDLER,UPDATEHANDLER";
+	/**
+	 * Combined category parameter value for both query and update handler MBeans
+	 * requests
+	 */
+	private static final String HANDLER_CATEGORIES = "QUERYHANDLER,UPDATEHANDLER";
 
-    /** Universal Solr query pattern to match all documents in a collection */
-    private static final String ALL_DOCUMENTS_QUERY = "*:*";
+	/** Universal Solr query pattern to match all documents in a collection */
+	private static final String ALL_DOCUMENTS_QUERY = "*:*";
 
-    /** Suffix pattern used to identify shard names in SolrCloud deployments */
-    private static final String SHARD_SUFFIX = "_shard";
+	/** Suffix pattern used to identify shard names in SolrCloud deployments */
+	private static final String SHARD_SUFFIX = "_shard";
 
-    /** Request parameter name for enabling statistics in MBeans requests */
-    private static final String STATS_PARAM = "stats";
+	/** Request parameter name for enabling statistics in MBeans requests */
+	private static final String STATS_PARAM = "stats";
 
-    /** Request parameter name for specifying category filters in MBeans requests */
-    private static final String CAT_PARAM = "cat";
+	/** Request parameter name for specifying category filters in MBeans requests */
+	private static final String CAT_PARAM = "cat";
 
-    /** Request parameter name for specifying response writer type */
-    private static final String WT_PARAM = "wt";
+	/** Request parameter name for specifying response writer type */
+	private static final String WT_PARAM = "wt";
 
-    /** JSON format specification for response writer type */
-    private static final String JSON_FORMAT = "json";
+	/** JSON format specification for response writer type */
+	private static final String JSON_FORMAT = "json";
 
-    // ========================================
-    // Constants for Response Parsing
-    // ========================================
+	// ========================================
+	// Constants for Response Parsing
+	// ========================================
 
-    /** Key name for collections list in Collections API responses */
-    private static final String COLLECTIONS_KEY = "collections";
+	/** Key name for collections list in Collections API responses */
+	private static final String COLLECTIONS_KEY = "collections";
 
-    /** Key name for segment count information in Luke response */
-    private static final String SEGMENT_COUNT_KEY = "segmentCount";
+	/** Key name for segment count information in Luke response */
+	private static final String SEGMENT_COUNT_KEY = "segmentCount";
 
-    /** Key name for query result cache in MBeans cache responses */
-    private static final String QUERY_RESULT_CACHE_KEY = "queryResultCache";
+	/** Key name for query result cache in MBeans cache responses */
+	private static final String QUERY_RESULT_CACHE_KEY = "queryResultCache";
 
-    /** Key name for document cache in MBeans cache responses */
-    private static final String DOCUMENT_CACHE_KEY = "documentCache";
+	/** Key name for document cache in MBeans cache responses */
+	private static final String DOCUMENT_CACHE_KEY = "documentCache";
 
-    /** Key name for filter cache in MBeans cache responses */
-    private static final String FILTER_CACHE_KEY = "filterCache";
+	/** Key name for filter cache in MBeans cache responses */
+	private static final String FILTER_CACHE_KEY = "filterCache";
 
-    /** Key name for statistics section in MBeans responses */
-    private static final String STATS_KEY = "stats";
+	/** Key name for statistics section in MBeans responses */
+	private static final String STATS_KEY = "stats";
 
-    // ========================================
-    // Constants for Handler Paths
-    // ========================================
-
-    /** URL path for Solr select (query) handler */
-    private static final String SELECT_HANDLER_PATH = "/select";
-
-    /** URL path for Solr update handler */
-    private static final String UPDATE_HANDLER_PATH = "/update";
-
-    /** URL path for Solr MBeans admin endpoint */
-    private static final String ADMIN_MBEANS_PATH = "/admin/mbeans";
-
-    // ========================================
-    // Constants for Statistics Field Names
-    // ========================================
-
-    /** Field name for cache/handler lookup count statistics */
-    private static final String LOOKUPS_FIELD = "lookups";
-
-    /** Field name for cache hit count statistics */
-    private static final String HITS_FIELD = "hits";
-
-    /** Field name for cache hit ratio statistics */
-    private static final String HITRATIO_FIELD = "hitratio";
-
-    /** Field name for cache insert count statistics */
-    private static final String INSERTS_FIELD = "inserts";
-
-    /** Field name for cache eviction count statistics */
-    private static final String EVICTIONS_FIELD = "evictions";
-
-    /** Field name for cache size statistics */
-    private static final String SIZE_FIELD = "size";
-
-    /** Field name for handler request count statistics */
-    private static final String REQUESTS_FIELD = "requests";
-
-    /** Field name for handler error count statistics */
-    private static final String ERRORS_FIELD = "errors";
-
-    /** Field name for handler timeout count statistics */
-    private static final String TIMEOUTS_FIELD = "timeouts";
-
-    /** Field name for handler total processing time statistics */
-    private static final String TOTAL_TIME_FIELD = "totalTime";
-
-    /** Field name for handler average time per request statistics */
-    private static final String AVG_TIME_PER_REQUEST_FIELD = "avgTimePerRequest";
-
-    /** Field name for handler average requests per second statistics */
-    private static final String AVG_REQUESTS_PER_SECOND_FIELD = "avgRequestsPerSecond";
-
-    // ========================================
-    // Constants for Error Messages
-    // ========================================
-
-    /** Error message prefix for collection not found exceptions */
-    private static final String COLLECTION_NOT_FOUND_ERROR = "Collection not found: ";
-
-    /** SolrJ client for communicating with Solr server */
-    private final SolrClient solrClient;
-
-    /**
-     * Constructs a new CollectionService with the required dependencies.
-     *
-     * 

This constructor is automatically called by Spring's dependency injection framework during - * application startup. - * - * @param solrClient the SolrJ client instance for communicating with Solr - * @see SolrClient - * @see SolrConfigurationProperties - */ - public CollectionService(SolrClient solrClient) { - this.solrClient = solrClient; - } - - /** - * Lists all available Solr collections or cores in the cluster. - * - *

This method automatically detects the Solr deployment type and uses the appropriate API: - * - *

    - *
  • SolrCloud: Uses Collections API to list distributed collections - *
  • Standalone: Uses Core Admin API to list individual cores - *
- * - *

In SolrCloud environments, the returned names may include shard identifiers (e.g., - * "films_shard1_replica_n1"). Use {@link #extractCollectionName(String)} to get the base - * collection name if needed. - * - *

Error Handling: - * - *

If the operation fails due to connectivity issues or API errors, an empty list is returned - * rather than throwing an exception, allowing the application to continue functioning with - * degraded capabilities. - * - *

MCP Tool Usage: - * - *

This method is exposed as an MCP tool and can be invoked by AI clients with natural - * language requests like "list all collections" or "show me available databases". - * - * @return a list of collection/core names, or an empty list if unable to retrieve them - * @see CollectionAdminRequest.List - * @see CoreAdminRequest - */ - @McpTool(description = "List solr collections") - public List listCollections() { - try { - if (solrClient instanceof CloudSolrClient) { - // For SolrCloud - use Collections API - CollectionAdminRequest.List request = new CollectionAdminRequest.List(); - CollectionAdminResponse response = request.process(solrClient); - - @SuppressWarnings("unchecked") - List collections = - (List) response.getResponse().get(COLLECTIONS_KEY); - return collections != null ? collections : new ArrayList<>(); - } else { - // For standalone Solr - use Core Admin API - CoreAdminRequest coreAdminRequest = new CoreAdminRequest(); - coreAdminRequest.setAction(CoreAdminParams.CoreAdminAction.STATUS); - CoreAdminResponse coreResponse = coreAdminRequest.process(solrClient); - - List cores = new ArrayList<>(); - NamedList> coreStatus = coreResponse.getCoreStatus(); - for (int i = 0; i < coreStatus.size(); i++) { - cores.add(coreStatus.getName(i)); - } - return cores; - } - } catch (SolrServerException | IOException e) { - return new ArrayList<>(); - } - } - - /** - * Retrieves comprehensive performance metrics and statistics for a specified Solr collection. - * - *

This method aggregates metrics from multiple Solr endpoints to provide a complete - * performance profile including index health, query performance, cache utilization, and request - * handler statistics. - * - *

Collected Metrics: - * - *

    - *
  • Index Statistics: Document counts, segment information (via Luke - * handler) - *
  • Query Performance: Response times, result counts, relevance scores - *
  • Cache Utilization: Hit ratios, eviction rates for all cache types - *
  • Handler Performance: Request volumes, error rates, throughput metrics - *
- * - *

Collection Name Handling: - * - *

Supports both collection names and shard names. If a shard name like - * "films_shard1_replica_n1" is provided, it will be automatically converted to the base - * collection name "films" for API calls. - * - *

Validation: - * - *

The method validates that the specified collection exists before attempting to collect - * metrics. If the collection is not found, an {@code IllegalArgumentException} is thrown with a - * descriptive error message. - * - *

MCP Tool Usage: - * - *

Exposed as an MCP tool for natural language queries like "get metrics for my_collection" - * or "show me performance stats for the search index". - * - * @param collection the name of the collection to analyze (supports both collection and shard - * names) - * @return comprehensive metrics object containing all collected statistics - * @throws IllegalArgumentException if the specified collection does not exist - * @throws SolrServerException if there are errors communicating with Solr - * @throws IOException if there are I/O errors during communication - * @see SolrMetrics - * @see LukeRequest - * @see #extractCollectionName(String) - */ - @McpTool(description = "Get stats/metrics on a Solr collection") - public SolrMetrics getCollectionStats( - @McpToolParam(description = "Solr collection to get stats/metrics for") - String collection) - throws SolrServerException, IOException { - // Extract actual collection name from shard name if needed - String actualCollection = extractCollectionName(collection); - - // Validate collection exists - if (!validateCollectionExists(actualCollection)) { - throw new IllegalArgumentException(COLLECTION_NOT_FOUND_ERROR + actualCollection); - } - - // Index statistics using Luke - LukeRequest lukeRequest = new LukeRequest(); - lukeRequest.setIncludeIndexFieldFlags(true); - LukeResponse lukeResponse = lukeRequest.process(solrClient, actualCollection); - - // Query performance metrics - QueryResponse statsResponse = - solrClient.query(actualCollection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); - - return new SolrMetrics( - buildIndexStats(lukeResponse), - buildQueryStats(statsResponse), - getCacheMetrics(actualCollection), - getHandlerMetrics(actualCollection), - new Date()); - } - - /** - * Builds an IndexStats object from a Solr Luke response containing index metadata. - * - *

The Luke handler provides low-level Lucene index information including document counts, - * segment details, and field statistics. This method extracts the essential index health - * metrics for monitoring and analysis. - * - *

Extracted Metrics: - * - *

    - *
  • numDocs: Total number of documents excluding deleted ones - *
  • segmentCount: Number of Lucene segments (performance indicator) - *
- * - *

Performance Implications: - * - *

High segment counts may indicate the need for index optimization to improve search - * performance. The optimal segment count depends on index size and update frequency. - * - * @param lukeResponse the Luke response containing raw index information - * @return IndexStats object with extracted and formatted metrics - * @see IndexStats - * @see LukeResponse - */ - public IndexStats buildIndexStats(LukeResponse lukeResponse) { - NamedList indexInfo = lukeResponse.getIndexInfo(); - - // Extract index information using helper methods - Integer segmentCount = getInteger(indexInfo, SEGMENT_COUNT_KEY); - - return new IndexStats(lukeResponse.getNumDocs(), segmentCount); - } - - /** - * Builds a QueryStats object from a Solr query response containing performance metrics. - * - *

Extracts key performance indicators from a query execution including timing, result - * characteristics, and relevance scoring information. These metrics help identify query - * performance patterns and optimization opportunities. - * - *

Extracted Metrics: - * - *

    - *
  • queryTime: Execution time in milliseconds - *
  • totalResults: Total matching documents found - *
  • start: Pagination offset (0-based) - *
  • maxScore: Highest relevance score in results - *
- * - *

Performance Analysis: - * - *

Query time metrics help identify slow queries that may need optimization, while result - * counts and scores provide insight into search effectiveness. - * - * @param response the query response containing performance and result metadata - * @return QueryStats object with extracted performance metrics - * @see QueryStats - * @see QueryResponse - */ - public QueryStats buildQueryStats(QueryResponse response) { - - return new QueryStats( - response.getQTime(), - response.getResults().getNumFound(), - response.getResults().getStart(), - response.getResults().getMaxScore()); - } - - /** - * Retrieves cache performance metrics for all cache types in a Solr collection. - * - *

Collects detailed cache utilization statistics from Solr's MBeans endpoint, providing - * insights into cache effectiveness and memory usage patterns. Cache performance directly - * impacts query response times and system efficiency. - * - *

Monitored Cache Types: - * - *

    - *
  • Query Result Cache: Caches complete query results for identical - * searches - *
  • Document Cache: Caches retrieved document field data - *
  • Filter Cache: Caches filter query results for faceting and filtering - *
- * - *

Key Performance Indicators: - * - *

    - *
  • Hit Ratio: Cache effectiveness (higher is better) - *
  • Evictions: Memory pressure indicator - *
  • Size: Current cache utilization - *
- * - *

Error Handling: - * - *

Returns {@code null} if cache statistics cannot be retrieved or if all cache types are - * empty/unavailable. This allows graceful degradation when cache monitoring is not available. - * - * @param collection the collection name to retrieve cache metrics for - * @return CacheStats object with all cache performance metrics, or null if unavailable - * @see CacheStats - * @see CacheInfo - * @see #extractCacheStats(NamedList) - * @see #isCacheStatsEmpty(CacheStats) - */ - public CacheStats getCacheMetrics(String collection) { - try { - // Get MBeans for cache information - ModifiableSolrParams params = new ModifiableSolrParams(); - params.set(STATS_PARAM, "true"); - params.set(CAT_PARAM, CACHE_CATEGORY); - params.set(WT_PARAM, JSON_FORMAT); - - // Extract actual collection name from shard name if needed - String actualCollection = extractCollectionName(collection); - - // Validate collection exists first - if (!validateCollectionExists(actualCollection)) { - return null; // Return null instead of empty object - } - - String path = "/" + actualCollection + ADMIN_MBEANS_PATH; - - GenericSolrRequest request = - new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); - - NamedList response = solrClient.request(request); - CacheStats stats = extractCacheStats(response); - - // Return null if all cache stats are empty/null - if (isCacheStatsEmpty(stats)) { - return null; - } - - return stats; - } catch (SolrServerException | IOException e) { - return null; // Return null instead of empty object - } - } - - /** - * Checks if cache statistics are empty or contain no meaningful data. - * - *

Used to determine whether cache metrics are worth returning to clients. Empty cache stats - * typically indicate that caches are not configured or not yet populated with data. - * - * @param stats the cache statistics to evaluate - * @return true if the stats are null or all cache types are null - */ - private boolean isCacheStatsEmpty(CacheStats stats) { - return stats == null - || (stats.queryResultCache() == null - && stats.documentCache() == null - && stats.filterCache() == null); - } - - /** - * Extracts cache performance statistics from Solr MBeans response data. - * - *

Parses the raw MBeans response to extract structured cache performance metrics for all - * available cache types. Each cache type provides detailed statistics including hit ratios, - * eviction rates, and current utilization. - * - *

Parsed Cache Types: - * - *

    - *
  • queryResultCache - Complete query result caching - *
  • documentCache - Retrieved document data caching - *
  • filterCache - Filter query result caching - *
- * - *

For each cache type, the following metrics are extracted: - * - *

    - *
  • lookups, hits, hitratio - Performance effectiveness - *
  • inserts, evictions - Memory management patterns - *
  • size - Current utilization - *
- * - * @param mbeans the raw MBeans response from Solr admin endpoint - * @return CacheStats object containing parsed metrics for all cache types - * @see CacheStats - * @see CacheInfo - */ - private CacheStats extractCacheStats(NamedList mbeans) { - CacheInfo queryResultCacheInfo = null; - CacheInfo documentCacheInfo = null; - CacheInfo filterCacheInfo = null; - - @SuppressWarnings("unchecked") - NamedList caches = (NamedList) mbeans.get(CACHE_CATEGORY); - - if (caches != null) { - // Query result cache - @SuppressWarnings("unchecked") - NamedList queryResultCache = - (NamedList) caches.get(QUERY_RESULT_CACHE_KEY); - if (queryResultCache != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) queryResultCache.get(STATS_KEY); - queryResultCacheInfo = - new CacheInfo( - getLong(stats, LOOKUPS_FIELD), - getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), - getLong(stats, INSERTS_FIELD), - getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD)); - } - - // Document cache - @SuppressWarnings("unchecked") - NamedList documentCache = (NamedList) caches.get(DOCUMENT_CACHE_KEY); - if (documentCache != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) documentCache.get(STATS_KEY); - documentCacheInfo = - new CacheInfo( - getLong(stats, LOOKUPS_FIELD), - getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), - getLong(stats, INSERTS_FIELD), - getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD)); - } - - // Filter cache - @SuppressWarnings("unchecked") - NamedList filterCache = (NamedList) caches.get(FILTER_CACHE_KEY); - if (filterCache != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) filterCache.get(STATS_KEY); - filterCacheInfo = - new CacheInfo( - getLong(stats, LOOKUPS_FIELD), - getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), - getLong(stats, INSERTS_FIELD), - getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD)); - } - } - - return new CacheStats(queryResultCacheInfo, documentCacheInfo, filterCacheInfo); - } - - /** - * Retrieves request handler performance metrics for core Solr operations. - * - *

Collects detailed performance statistics for the primary request handlers that process - * search and update operations. Handler metrics provide insights into system throughput, error - * rates, and response time characteristics. - * - *

Monitored Handlers: - * - *

    - *
  • Select Handler ({@value #SELECT_HANDLER_PATH}): Processes search and - * query requests - *
  • Update Handler ({@value #UPDATE_HANDLER_PATH}): Processes document - * indexing operations - *
- * - *

Performance Metrics: - * - *

    - *
  • Request Volume: Total requests processed - *
  • Error Rates: Failed request counts and timeouts - *
  • Performance: Average response times and throughput - *
- * - *

Error Handling: - * - *

Returns {@code null} if handler statistics cannot be retrieved or if no meaningful handler - * data is available. This allows graceful degradation when handler monitoring endpoints are not - * accessible. - * - * @param collection the collection name to retrieve handler metrics for - * @return HandlerStats object with performance metrics for all handlers, or null if unavailable - * @see HandlerStats - * @see HandlerInfo - * @see #extractHandlerStats(NamedList) - * @see #isHandlerStatsEmpty(HandlerStats) - */ - public HandlerStats getHandlerMetrics(String collection) { - try { - ModifiableSolrParams params = new ModifiableSolrParams(); - params.set(STATS_PARAM, "true"); - params.set(CAT_PARAM, HANDLER_CATEGORIES); - params.set(WT_PARAM, JSON_FORMAT); - - // Extract actual collection name from shard name if needed - String actualCollection = extractCollectionName(collection); - - // Validate collection exists first - if (!validateCollectionExists(actualCollection)) { - return null; // Return null instead of empty object - } - - String path = "/" + actualCollection + ADMIN_MBEANS_PATH; - - GenericSolrRequest request = - new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); - - NamedList response = solrClient.request(request); - HandlerStats stats = extractHandlerStats(response); - - // Return null if all handler stats are empty/null - if (isHandlerStatsEmpty(stats)) { - return null; - } - - return stats; - } catch (SolrServerException | IOException e) { - return null; // Return null instead of empty object - } - } - - /** - * Checks if handler statistics are empty or contain no meaningful data. - * - *

Used to determine whether handler metrics are worth returning to clients. Empty handler - * stats typically indicate that handlers haven't processed any requests yet or statistics - * collection is not enabled. - * - * @param stats the handler statistics to evaluate - * @return true if the stats are null or all handler types are null - */ - private boolean isHandlerStatsEmpty(HandlerStats stats) { - return stats == null || (stats.selectHandler() == null && stats.updateHandler() == null); - } - - /** - * Extracts request handler performance statistics from Solr MBeans response data. - * - *

Parses the raw MBeans response to extract structured handler performance metrics for query - * and update operations. Each handler provides detailed statistics about request processing - * including volume, errors, and timing. - * - *

Parsed Handler Types: - * - *

    - *
  • /select - Search and query request handler - *
  • /update - Document indexing request handler - *
- * - *

For each handler type, the following metrics are extracted: - * - *

    - *
  • requests, errors, timeouts - Volume and reliability - *
  • totalTime, avgTimePerRequest - Performance characteristics - *
  • avgRequestsPerSecond - Throughput capacity - *
- * - * @param mbeans the raw MBeans response from Solr admin endpoint - * @return HandlerStats object containing parsed metrics for all handler types - * @see HandlerStats - * @see HandlerInfo - */ - private HandlerStats extractHandlerStats(NamedList mbeans) { - HandlerInfo selectHandlerInfo = null; - HandlerInfo updateHandlerInfo = null; - - @SuppressWarnings("unchecked") - NamedList queryHandlers = (NamedList) mbeans.get(QUERY_HANDLER_CATEGORY); - - if (queryHandlers != null) { - // Select handler - @SuppressWarnings("unchecked") - NamedList selectHandler = - (NamedList) queryHandlers.get(SELECT_HANDLER_PATH); - if (selectHandler != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) selectHandler.get(STATS_KEY); - selectHandlerInfo = - new HandlerInfo( - getLong(stats, REQUESTS_FIELD), - getLong(stats, ERRORS_FIELD), - getLong(stats, TIMEOUTS_FIELD), - getLong(stats, TOTAL_TIME_FIELD), - getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), - getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); - } - - // Update handler - @SuppressWarnings("unchecked") - NamedList updateHandler = - (NamedList) queryHandlers.get(UPDATE_HANDLER_PATH); - if (updateHandler != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) updateHandler.get(STATS_KEY); - updateHandlerInfo = - new HandlerInfo( - getLong(stats, REQUESTS_FIELD), - getLong(stats, ERRORS_FIELD), - getLong(stats, TIMEOUTS_FIELD), - getLong(stats, TOTAL_TIME_FIELD), - getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), - getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); - } - } - - return new HandlerStats(selectHandlerInfo, updateHandlerInfo); - } - - /** - * Extracts the actual collection name from a shard name in SolrCloud environments. - * - *

In SolrCloud deployments, collection operations often return shard names that include - * replica and shard identifiers (e.g., "films_shard1_replica_n1"). This method extracts the - * base collection name ("films") for use in API calls that require the collection name. - * - *

Extraction Logic: - * - *

    - *
  • Detects shard patterns containing the {@value #SHARD_SUFFIX} suffix - *
  • Returns the substring before the shard identifier - *
  • Returns the original string if no shard pattern is detected - *
- * - *

Examples: - * - *

    - *
  • "films_shard1_replica_n1" → "films" - *
  • "products_shard2_replica_n3" → "products" - *
  • "simple_collection" → "simple_collection" (unchanged) - *
- * - * @param collectionOrShard the collection or shard name to parse - * @return the extracted collection name, or the original string if no shard pattern found - */ - String extractCollectionName(String collectionOrShard) { - if (collectionOrShard == null || collectionOrShard.isEmpty()) { - return collectionOrShard; - } - - // Check if this looks like a shard name (contains "_shard" pattern) - if (collectionOrShard.contains(SHARD_SUFFIX)) { - // Extract collection name before "_shard" - int shardIndex = collectionOrShard.indexOf(SHARD_SUFFIX); - return collectionOrShard.substring(0, shardIndex); - } - - // If it doesn't look like a shard name, return as-is - return collectionOrShard; - } - - /** - * Validates that a specified collection exists in the Solr cluster. - * - *

Performs collection existence validation by checking against the list of available - * collections. Supports both exact collection name matches and shard-based matching for - * SolrCloud environments. - * - *

Validation Strategy: - * - *

    - *
  1. Exact Match: Checks if the collection name exists exactly - *
  2. Shard Match: Checks if any shards start with "collection{@value - * #SHARD_SUFFIX}" pattern - *
- * - *

This dual approach ensures compatibility with both standalone Solr (which returns core - * names directly) and SolrCloud (which may return shard names). - * - *

Error Handling: - * - *

Returns {@code false} if validation fails due to communication errors, allowing calling - * methods to handle missing collections appropriately. - * - * @param collection the collection name to validate - * @return true if the collection exists (either exact or shard match), false otherwise - * @see #listCollections() - * @see #extractCollectionName(String) - */ - private boolean validateCollectionExists(String collection) { - try { - List collections = listCollections(); - - // Check for exact match first - if (collections.contains(collection)) { - return true; - } - - // Check if any of the returned collections start with the collection name (for shard - // names) - boolean shardMatch = - collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX)); - - return shardMatch; - } catch (Exception e) { - return false; - } - } - - /** - * Performs a comprehensive health check on a Solr collection. - * - *

Evaluates collection availability and performance by executing a ping operation and basic - * query to gather health indicators. This method provides a quick way to determine if a - * collection is operational and responding to requests. - * - *

Health Check Components: - * - *

    - *
  • Availability: Collection responds to ping requests - *
  • Performance: Response time measurement - *
  • Content: Document count verification using universal query ({@value - * #ALL_DOCUMENTS_QUERY}) - *
  • Timestamp: When the check was performed - *
- * - *

Success Criteria: - * - *

A collection is considered healthy if both the ping operation and a basic query complete - * successfully without exceptions. Performance metrics are collected during the health check - * process. - * - *

Failure Handling: - * - *

If the health check fails, a status object is returned with {@code isHealthy=false} and - * the error message describing the failure reason. This allows monitoring systems to identify - * specific issues. - * - *

MCP Tool Usage: - * - *

Exposed as an MCP tool for natural language health queries like "check if my_collection is - * healthy" or "is the search index working properly". - * - * @param collection the name of the collection to health check - * @return SolrHealthStatus object containing health assessment results - * @see SolrHealthStatus - * @see SolrPingResponse - */ - @McpTool(description = "Check health of a Solr collection") - public SolrHealthStatus checkHealth( - @McpToolParam(description = "Solr collection") String collection) { - try { - // Ping Solr - SolrPingResponse pingResponse = solrClient.ping(collection); - - // Get basic stats - QueryResponse statsResponse = - solrClient.query(collection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); - - return new SolrHealthStatus( - true, - null, - pingResponse.getElapsedTime(), - statsResponse.getResults().getNumFound(), - new Date(), - null, - null, - null); - - } catch (Exception e) { - return new SolrHealthStatus( - false, e.getMessage(), null, null, new Date(), null, null, null); - } - } + // ======================================== + // Constants for Handler Paths + // ======================================== + + /** URL path for Solr select (query) handler */ + private static final String SELECT_HANDLER_PATH = "/select"; + + /** URL path for Solr update handler */ + private static final String UPDATE_HANDLER_PATH = "/update"; + + /** URL path for Solr MBeans admin endpoint */ + private static final String ADMIN_MBEANS_PATH = "/admin/mbeans"; + + // ======================================== + // Constants for Statistics Field Names + // ======================================== + + /** Field name for cache/handler lookup count statistics */ + private static final String LOOKUPS_FIELD = "lookups"; + + /** Field name for cache hit count statistics */ + private static final String HITS_FIELD = "hits"; + + /** Field name for cache hit ratio statistics */ + private static final String HITRATIO_FIELD = "hitratio"; + + /** Field name for cache insert count statistics */ + private static final String INSERTS_FIELD = "inserts"; + + /** Field name for cache eviction count statistics */ + private static final String EVICTIONS_FIELD = "evictions"; + + /** Field name for cache size statistics */ + private static final String SIZE_FIELD = "size"; + + /** Field name for handler request count statistics */ + private static final String REQUESTS_FIELD = "requests"; + + /** Field name for handler error count statistics */ + private static final String ERRORS_FIELD = "errors"; + + /** Field name for handler timeout count statistics */ + private static final String TIMEOUTS_FIELD = "timeouts"; + + /** Field name for handler total processing time statistics */ + private static final String TOTAL_TIME_FIELD = "totalTime"; + + /** Field name for handler average time per request statistics */ + private static final String AVG_TIME_PER_REQUEST_FIELD = "avgTimePerRequest"; + + /** Field name for handler average requests per second statistics */ + private static final String AVG_REQUESTS_PER_SECOND_FIELD = "avgRequestsPerSecond"; + + // ======================================== + // Constants for Error Messages + // ======================================== + + /** Error message prefix for collection not found exceptions */ + private static final String COLLECTION_NOT_FOUND_ERROR = "Collection not found: "; + + /** SolrJ client for communicating with Solr server */ + private final SolrClient solrClient; + + /** + * Constructs a new CollectionService with the required dependencies. + * + *

+ * This constructor is automatically called by Spring's dependency injection + * framework during application startup. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + * @see SolrClient + * @see SolrConfigurationProperties + */ + public CollectionService(SolrClient solrClient) { + this.solrClient = solrClient; + } + + /** + * Lists all available Solr collections or cores in the cluster. + * + *

+ * This method automatically detects the Solr deployment type and uses the + * appropriate API: + * + *

    + *
  • SolrCloud: Uses Collections API to list distributed + * collections + *
  • Standalone: Uses Core Admin API to list individual cores + *
+ * + *

+ * In SolrCloud environments, the returned names may include shard identifiers + * (e.g., "films_shard1_replica_n1"). Use {@link #extractCollectionName(String)} + * to get the base collection name if needed. + * + *

+ * Error Handling: + * + *

+ * If the operation fails due to connectivity issues or API errors, an empty + * list is returned rather than throwing an exception, allowing the application + * to continue functioning with degraded capabilities. + * + *

+ * MCP Tool Usage: + * + *

+ * This method is exposed as an MCP tool and can be invoked by AI clients with + * natural language requests like "list all collections" or "show me available + * databases". + * + * @return a list of collection/core names, or an empty list if unable to + * retrieve them + * @see CollectionAdminRequest.List + * @see CoreAdminRequest + */ + @McpTool(description = "List solr collections") + public List listCollections() { + try { + if (solrClient instanceof CloudSolrClient) { + // For SolrCloud - use Collections API + CollectionAdminRequest.List request = new CollectionAdminRequest.List(); + CollectionAdminResponse response = request.process(solrClient); + + @SuppressWarnings("unchecked") + List collections = (List) response.getResponse().get(COLLECTIONS_KEY); + return collections != null ? collections : new ArrayList<>(); + } else { + // For standalone Solr - use Core Admin API + CoreAdminRequest coreAdminRequest = new CoreAdminRequest(); + coreAdminRequest.setAction(CoreAdminParams.CoreAdminAction.STATUS); + CoreAdminResponse coreResponse = coreAdminRequest.process(solrClient); + + List cores = new ArrayList<>(); + NamedList> coreStatus = coreResponse.getCoreStatus(); + for (int i = 0; i < coreStatus.size(); i++) { + cores.add(coreStatus.getName(i)); + } + return cores; + } + } catch (SolrServerException | IOException e) { + return new ArrayList<>(); + } + } + + /** + * Retrieves comprehensive performance metrics and statistics for a specified + * Solr collection. + * + *

+ * This method aggregates metrics from multiple Solr endpoints to provide a + * complete performance profile including index health, query performance, cache + * utilization, and request handler statistics. + * + *

+ * Collected Metrics: + * + *

    + *
  • Index Statistics: Document counts, segment information + * (via Luke handler) + *
  • Query Performance: Response times, result counts, + * relevance scores + *
  • Cache Utilization: Hit ratios, eviction rates for all + * cache types + *
  • Handler Performance: Request volumes, error rates, + * throughput metrics + *
+ * + *

+ * Collection Name Handling: + * + *

+ * Supports both collection names and shard names. If a shard name like + * "films_shard1_replica_n1" is provided, it will be automatically converted to + * the base collection name "films" for API calls. + * + *

+ * Validation: + * + *

+ * The method validates that the specified collection exists before attempting + * to collect metrics. If the collection is not found, an + * {@code IllegalArgumentException} is thrown with a descriptive error message. + * + *

+ * MCP Tool Usage: + * + *

+ * Exposed as an MCP tool for natural language queries like "get metrics for + * my_collection" or "show me performance stats for the search index". + * + * @param collection + * the name of the collection to analyze (supports both collection + * and shard names) + * @return comprehensive metrics object containing all collected statistics + * @throws IllegalArgumentException + * if the specified collection does not exist + * @throws SolrServerException + * if there are errors communicating with Solr + * @throws IOException + * if there are I/O errors during communication + * @see SolrMetrics + * @see LukeRequest + * @see #extractCollectionName(String) + */ + @McpTool(description = "Get stats/metrics on a Solr collection") + public SolrMetrics getCollectionStats( + @McpToolParam(description = "Solr collection to get stats/metrics for") String collection) + throws SolrServerException, IOException { + // Extract actual collection name from shard name if needed + String actualCollection = extractCollectionName(collection); + + // Validate collection exists + if (!validateCollectionExists(actualCollection)) { + throw new IllegalArgumentException(COLLECTION_NOT_FOUND_ERROR + actualCollection); + } + + // Index statistics using Luke + LukeRequest lukeRequest = new LukeRequest(); + lukeRequest.setIncludeIndexFieldFlags(true); + LukeResponse lukeResponse = lukeRequest.process(solrClient, actualCollection); + + // Query performance metrics + QueryResponse statsResponse = solrClient.query(actualCollection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); + + return new SolrMetrics(buildIndexStats(lukeResponse), buildQueryStats(statsResponse), + getCacheMetrics(actualCollection), getHandlerMetrics(actualCollection), new Date()); + } + + /** + * Builds an IndexStats object from a Solr Luke response containing index + * metadata. + * + *

+ * The Luke handler provides low-level Lucene index information including + * document counts, segment details, and field statistics. This method extracts + * the essential index health metrics for monitoring and analysis. + * + *

+ * Extracted Metrics: + * + *

    + *
  • numDocs: Total number of documents excluding deleted + * ones + *
  • segmentCount: Number of Lucene segments (performance + * indicator) + *
+ * + *

+ * Performance Implications: + * + *

+ * High segment counts may indicate the need for index optimization to improve + * search performance. The optimal segment count depends on index size and + * update frequency. + * + * @param lukeResponse + * the Luke response containing raw index information + * @return IndexStats object with extracted and formatted metrics + * @see IndexStats + * @see LukeResponse + */ + public IndexStats buildIndexStats(LukeResponse lukeResponse) { + NamedList indexInfo = lukeResponse.getIndexInfo(); + + // Extract index information using helper methods + Integer segmentCount = getInteger(indexInfo, SEGMENT_COUNT_KEY); + + return new IndexStats(lukeResponse.getNumDocs(), segmentCount); + } + + /** + * Builds a QueryStats object from a Solr query response containing performance + * metrics. + * + *

+ * Extracts key performance indicators from a query execution including timing, + * result characteristics, and relevance scoring information. These metrics help + * identify query performance patterns and optimization opportunities. + * + *

+ * Extracted Metrics: + * + *

    + *
  • queryTime: Execution time in milliseconds + *
  • totalResults: Total matching documents found + *
  • start: Pagination offset (0-based) + *
  • maxScore: Highest relevance score in results + *
+ * + *

+ * Performance Analysis: + * + *

+ * Query time metrics help identify slow queries that may need optimization, + * while result counts and scores provide insight into search effectiveness. + * + * @param response + * the query response containing performance and result metadata + * @return QueryStats object with extracted performance metrics + * @see QueryStats + * @see QueryResponse + */ + public QueryStats buildQueryStats(QueryResponse response) { + + return new QueryStats(response.getQTime(), response.getResults().getNumFound(), + response.getResults().getStart(), response.getResults().getMaxScore()); + } + + /** + * Retrieves cache performance metrics for all cache types in a Solr collection. + * + *

+ * Collects detailed cache utilization statistics from Solr's MBeans endpoint, + * providing insights into cache effectiveness and memory usage patterns. Cache + * performance directly impacts query response times and system efficiency. + * + *

+ * Monitored Cache Types: + * + *

    + *
  • Query Result Cache: Caches complete query results for + * identical searches + *
  • Document Cache: Caches retrieved document field data + *
  • Filter Cache: Caches filter query results for faceting + * and filtering + *
+ * + *

+ * Key Performance Indicators: + * + *

    + *
  • Hit Ratio: Cache effectiveness (higher is better) + *
  • Evictions: Memory pressure indicator + *
  • Size: Current cache utilization + *
+ * + *

+ * Error Handling: + * + *

+ * Returns {@code null} if cache statistics cannot be retrieved or if all cache + * types are empty/unavailable. This allows graceful degradation when cache + * monitoring is not available. + * + * @param collection + * the collection name to retrieve cache metrics for + * @return CacheStats object with all cache performance metrics, or null if + * unavailable + * @see CacheStats + * @see CacheInfo + * @see #extractCacheStats(NamedList) + * @see #isCacheStatsEmpty(CacheStats) + */ + public CacheStats getCacheMetrics(String collection) { + try { + // Get MBeans for cache information + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set(STATS_PARAM, "true"); + params.set(CAT_PARAM, CACHE_CATEGORY); + params.set(WT_PARAM, JSON_FORMAT); + + // Extract actual collection name from shard name if needed + String actualCollection = extractCollectionName(collection); + + // Validate collection exists first + if (!validateCollectionExists(actualCollection)) { + return null; // Return null instead of empty object + } + + String path = "/" + actualCollection + ADMIN_MBEANS_PATH; + + GenericSolrRequest request = new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); + + NamedList response = solrClient.request(request); + CacheStats stats = extractCacheStats(response); + + // Return null if all cache stats are empty/null + if (isCacheStatsEmpty(stats)) { + return null; + } + + return stats; + } catch (SolrServerException | IOException e) { + return null; // Return null instead of empty object + } + } + + /** + * Checks if cache statistics are empty or contain no meaningful data. + * + *

+ * Used to determine whether cache metrics are worth returning to clients. Empty + * cache stats typically indicate that caches are not configured or not yet + * populated with data. + * + * @param stats + * the cache statistics to evaluate + * @return true if the stats are null or all cache types are null + */ + private boolean isCacheStatsEmpty(CacheStats stats) { + return stats == null + || (stats.queryResultCache() == null && stats.documentCache() == null && stats.filterCache() == null); + } + + /** + * Extracts cache performance statistics from Solr MBeans response data. + * + *

+ * Parses the raw MBeans response to extract structured cache performance + * metrics for all available cache types. Each cache type provides detailed + * statistics including hit ratios, eviction rates, and current utilization. + * + *

+ * Parsed Cache Types: + * + *

    + *
  • queryResultCache - Complete query result caching + *
  • documentCache - Retrieved document data caching + *
  • filterCache - Filter query result caching + *
+ * + *

+ * For each cache type, the following metrics are extracted: + * + *

    + *
  • lookups, hits, hitratio - Performance effectiveness + *
  • inserts, evictions - Memory management patterns + *
  • size - Current utilization + *
+ * + * @param mbeans + * the raw MBeans response from Solr admin endpoint + * @return CacheStats object containing parsed metrics for all cache types + * @see CacheStats + * @see CacheInfo + */ + private CacheStats extractCacheStats(NamedList mbeans) { + CacheInfo queryResultCacheInfo = null; + CacheInfo documentCacheInfo = null; + CacheInfo filterCacheInfo = null; + + @SuppressWarnings("unchecked") + NamedList caches = (NamedList) mbeans.get(CACHE_CATEGORY); + + if (caches != null) { + // Query result cache + @SuppressWarnings("unchecked") + NamedList queryResultCache = (NamedList) caches.get(QUERY_RESULT_CACHE_KEY); + if (queryResultCache != null) { + @SuppressWarnings("unchecked") + NamedList stats = (NamedList) queryResultCache.get(STATS_KEY); + queryResultCacheInfo = new CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD), + getFloat(stats, HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD), + getLong(stats, SIZE_FIELD)); + } + + // Document cache + @SuppressWarnings("unchecked") + NamedList documentCache = (NamedList) caches.get(DOCUMENT_CACHE_KEY); + if (documentCache != null) { + @SuppressWarnings("unchecked") + NamedList stats = (NamedList) documentCache.get(STATS_KEY); + documentCacheInfo = new CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD), + getFloat(stats, HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD), + getLong(stats, SIZE_FIELD)); + } + + // Filter cache + @SuppressWarnings("unchecked") + NamedList filterCache = (NamedList) caches.get(FILTER_CACHE_KEY); + if (filterCache != null) { + @SuppressWarnings("unchecked") + NamedList stats = (NamedList) filterCache.get(STATS_KEY); + filterCacheInfo = new CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD), + getFloat(stats, HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD), + getLong(stats, SIZE_FIELD)); + } + } + + return new CacheStats(queryResultCacheInfo, documentCacheInfo, filterCacheInfo); + } + + /** + * Retrieves request handler performance metrics for core Solr operations. + * + *

+ * Collects detailed performance statistics for the primary request handlers + * that process search and update operations. Handler metrics provide insights + * into system throughput, error rates, and response time characteristics. + * + *

+ * Monitored Handlers: + * + *

    + *
  • Select Handler ({@value #SELECT_HANDLER_PATH}): + * Processes search and query requests + *
  • Update Handler ({@value #UPDATE_HANDLER_PATH}): + * Processes document indexing operations + *
+ * + *

+ * Performance Metrics: + * + *

    + *
  • Request Volume: Total requests processed + *
  • Error Rates: Failed request counts and timeouts + *
  • Performance: Average response times and throughput + *
+ * + *

+ * Error Handling: + * + *

+ * Returns {@code null} if handler statistics cannot be retrieved or if no + * meaningful handler data is available. This allows graceful degradation when + * handler monitoring endpoints are not accessible. + * + * @param collection + * the collection name to retrieve handler metrics for + * @return HandlerStats object with performance metrics for all handlers, or + * null if unavailable + * @see HandlerStats + * @see HandlerInfo + * @see #extractHandlerStats(NamedList) + * @see #isHandlerStatsEmpty(HandlerStats) + */ + public HandlerStats getHandlerMetrics(String collection) { + try { + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set(STATS_PARAM, "true"); + params.set(CAT_PARAM, HANDLER_CATEGORIES); + params.set(WT_PARAM, JSON_FORMAT); + + // Extract actual collection name from shard name if needed + String actualCollection = extractCollectionName(collection); + + // Validate collection exists first + if (!validateCollectionExists(actualCollection)) { + return null; // Return null instead of empty object + } + + String path = "/" + actualCollection + ADMIN_MBEANS_PATH; + + GenericSolrRequest request = new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); + + NamedList response = solrClient.request(request); + HandlerStats stats = extractHandlerStats(response); + + // Return null if all handler stats are empty/null + if (isHandlerStatsEmpty(stats)) { + return null; + } + + return stats; + } catch (SolrServerException | IOException e) { + return null; // Return null instead of empty object + } + } + + /** + * Checks if handler statistics are empty or contain no meaningful data. + * + *

+ * Used to determine whether handler metrics are worth returning to clients. + * Empty handler stats typically indicate that handlers haven't processed any + * requests yet or statistics collection is not enabled. + * + * @param stats + * the handler statistics to evaluate + * @return true if the stats are null or all handler types are null + */ + private boolean isHandlerStatsEmpty(HandlerStats stats) { + return stats == null || (stats.selectHandler() == null && stats.updateHandler() == null); + } + + /** + * Extracts request handler performance statistics from Solr MBeans response + * data. + * + *

+ * Parses the raw MBeans response to extract structured handler performance + * metrics for query and update operations. Each handler provides detailed + * statistics about request processing including volume, errors, and timing. + * + *

+ * Parsed Handler Types: + * + *

    + *
  • /select - Search and query request handler + *
  • /update - Document indexing request handler + *
+ * + *

+ * For each handler type, the following metrics are extracted: + * + *

    + *
  • requests, errors, timeouts - Volume and reliability + *
  • totalTime, avgTimePerRequest - Performance characteristics + *
  • avgRequestsPerSecond - Throughput capacity + *
+ * + * @param mbeans + * the raw MBeans response from Solr admin endpoint + * @return HandlerStats object containing parsed metrics for all handler types + * @see HandlerStats + * @see HandlerInfo + */ + private HandlerStats extractHandlerStats(NamedList mbeans) { + HandlerInfo selectHandlerInfo = null; + HandlerInfo updateHandlerInfo = null; + + @SuppressWarnings("unchecked") + NamedList queryHandlers = (NamedList) mbeans.get(QUERY_HANDLER_CATEGORY); + + if (queryHandlers != null) { + // Select handler + @SuppressWarnings("unchecked") + NamedList selectHandler = (NamedList) queryHandlers.get(SELECT_HANDLER_PATH); + if (selectHandler != null) { + @SuppressWarnings("unchecked") + NamedList stats = (NamedList) selectHandler.get(STATS_KEY); + selectHandlerInfo = new HandlerInfo(getLong(stats, REQUESTS_FIELD), getLong(stats, ERRORS_FIELD), + getLong(stats, TIMEOUTS_FIELD), getLong(stats, TOTAL_TIME_FIELD), + getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); + } + + // Update handler + @SuppressWarnings("unchecked") + NamedList updateHandler = (NamedList) queryHandlers.get(UPDATE_HANDLER_PATH); + if (updateHandler != null) { + @SuppressWarnings("unchecked") + NamedList stats = (NamedList) updateHandler.get(STATS_KEY); + updateHandlerInfo = new HandlerInfo(getLong(stats, REQUESTS_FIELD), getLong(stats, ERRORS_FIELD), + getLong(stats, TIMEOUTS_FIELD), getLong(stats, TOTAL_TIME_FIELD), + getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); + } + } + + return new HandlerStats(selectHandlerInfo, updateHandlerInfo); + } + + /** + * Extracts the actual collection name from a shard name in SolrCloud + * environments. + * + *

+ * In SolrCloud deployments, collection operations often return shard names that + * include replica and shard identifiers (e.g., "films_shard1_replica_n1"). This + * method extracts the base collection name ("films") for use in API calls that + * require the collection name. + * + *

+ * Extraction Logic: + * + *

    + *
  • Detects shard patterns containing the {@value #SHARD_SUFFIX} suffix + *
  • Returns the substring before the shard identifier + *
  • Returns the original string if no shard pattern is detected + *
+ * + *

+ * Examples: + * + *

    + *
  • "films_shard1_replica_n1" → "films" + *
  • "products_shard2_replica_n3" → "products" + *
  • "simple_collection" → "simple_collection" (unchanged) + *
+ * + * @param collectionOrShard + * the collection or shard name to parse + * @return the extracted collection name, or the original string if no shard + * pattern found + */ + String extractCollectionName(String collectionOrShard) { + if (collectionOrShard == null || collectionOrShard.isEmpty()) { + return collectionOrShard; + } + + // Check if this looks like a shard name (contains "_shard" pattern) + if (collectionOrShard.contains(SHARD_SUFFIX)) { + // Extract collection name before "_shard" + int shardIndex = collectionOrShard.indexOf(SHARD_SUFFIX); + return collectionOrShard.substring(0, shardIndex); + } + + // If it doesn't look like a shard name, return as-is + return collectionOrShard; + } + + /** + * Validates that a specified collection exists in the Solr cluster. + * + *

+ * Performs collection existence validation by checking against the list of + * available collections. Supports both exact collection name matches and + * shard-based matching for SolrCloud environments. + * + *

+ * Validation Strategy: + * + *

    + *
  1. Exact Match: Checks if the collection name exists + * exactly + *
  2. Shard Match: Checks if any shards start with + * "collection{@value #SHARD_SUFFIX}" pattern + *
+ * + *

+ * This dual approach ensures compatibility with both standalone Solr (which + * returns core names directly) and SolrCloud (which may return shard names). + * + *

+ * Error Handling: + * + *

+ * Returns {@code false} if validation fails due to communication errors, + * allowing calling methods to handle missing collections appropriately. + * + * @param collection + * the collection name to validate + * @return true if the collection exists (either exact or shard match), false + * otherwise + * @see #listCollections() + * @see #extractCollectionName(String) + */ + private boolean validateCollectionExists(String collection) { + try { + List collections = listCollections(); + + // Check for exact match first + if (collections.contains(collection)) { + return true; + } + + // Check if any of the returned collections start with the collection name (for + // shard + // names) + boolean shardMatch = collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX)); + + return shardMatch; + } catch (Exception e) { + return false; + } + } + + /** + * Performs a comprehensive health check on a Solr collection. + * + *

+ * Evaluates collection availability and performance by executing a ping + * operation and basic query to gather health indicators. This method provides a + * quick way to determine if a collection is operational and responding to + * requests. + * + *

+ * Health Check Components: + * + *

    + *
  • Availability: Collection responds to ping requests + *
  • Performance: Response time measurement + *
  • Content: Document count verification using universal + * query ({@value #ALL_DOCUMENTS_QUERY}) + *
  • Timestamp: When the check was performed + *
+ * + *

+ * Success Criteria: + * + *

+ * A collection is considered healthy if both the ping operation and a basic + * query complete successfully without exceptions. Performance metrics are + * collected during the health check process. + * + *

+ * Failure Handling: + * + *

+ * If the health check fails, a status object is returned with + * {@code isHealthy=false} and the error message describing the failure reason. + * This allows monitoring systems to identify specific issues. + * + *

+ * MCP Tool Usage: + * + *

+ * Exposed as an MCP tool for natural language health queries like "check if + * my_collection is healthy" or "is the search index working properly". + * + * @param collection + * the name of the collection to health check + * @return SolrHealthStatus object containing health assessment results + * @see SolrHealthStatus + * @see SolrPingResponse + */ + @McpTool(description = "Check health of a Solr collection") + public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection") String collection) { + try { + // Ping Solr + SolrPingResponse pingResponse = solrClient.ping(collection); + + // Get basic stats + QueryResponse statsResponse = solrClient.query(collection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); + + return new SolrHealthStatus(true, null, pingResponse.getElapsedTime(), + statsResponse.getResults().getNumFound(), new Date(), null, null, null); + + } catch (Exception e) { + return new SolrHealthStatus(false, e.getMessage(), null, null, new Date(), null, null, null); + } + } } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java index fe3c621..ed54157 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java @@ -19,35 +19,44 @@ import org.apache.solr.common.util.NamedList; /** - * Utility class providing type-safe helper methods for extracting values from Apache Solr NamedList - * objects. + * Utility class providing type-safe helper methods for extracting values from + * Apache Solr NamedList objects. * - *

This utility class simplifies the process of working with Solr's {@code NamedList} response - * format by providing robust type conversion methods that handle various data formats and edge - * cases commonly encountered when processing Solr admin and query responses. + *

+ * This utility class simplifies the process of working with Solr's + * {@code NamedList} response format by providing robust type conversion methods + * that handle various data formats and edge cases commonly encountered when + * processing Solr admin and query responses. * - *

Key Benefits: + *

+ * Key Benefits: * *

    - *
  • Type Safety: Automatic conversion with proper error handling - *
  • Null Safety: Graceful handling of missing or null values - *
  • Format Flexibility: Support for multiple input data types - *
  • Error Resilience: Defensive programming against malformed data + *
  • Type Safety: Automatic conversion with proper error + * handling + *
  • Null Safety: Graceful handling of missing or null values + *
  • Format Flexibility: Support for multiple input data + * types + *
  • Error Resilience: Defensive programming against + * malformed data *
* - *

Common Use Cases: + *

+ * Common Use Cases: * *

    - *
  • Extracting metrics from Solr MBeans responses - *
  • Processing Luke handler index statistics - *
  • Converting admin API response values to typed objects - *
  • Handling cache and handler performance metrics + *
  • Extracting metrics from Solr MBeans responses + *
  • Processing Luke handler index statistics + *
  • Converting admin API response values to typed objects + *
  • Handling cache and handler performance metrics *
* - *

Thread Safety: + *

+ * Thread Safety: * - *

All methods in this utility class are stateless and thread-safe, making them suitable for use - * in concurrent environments and Spring service beans. + *

+ * All methods in this utility class are stateless and thread-safe, making them + * suitable for use in concurrent environments and Spring service beans. * * @version 0.0.1 * @since 0.0.1 @@ -56,158 +65,201 @@ */ public class CollectionUtils { - /** - * Extracts a Long value from a NamedList using the specified key with robust type conversion. - * - *

This method provides flexible extraction of Long values from Solr NamedList responses, - * handling various input formats that may be returned by different Solr endpoints. It performs - * safe type conversion with appropriate error handling for malformed data. - * - *

Supported Input Types: - * - *

    - *
  • Number instances: Integer, Long, Double, Float, BigInteger, BigDecimal - *
  • String representations: Numeric strings that can be parsed as Long - *
  • Null values: Returns null without throwing exceptions - *
- * - *

Error Handling: - * - *

Returns {@code null} for missing keys, null values, or unparseable strings rather than - * throwing exceptions, enabling graceful degradation in metrics collection scenarios. - * - *

Common Use Cases: - * - *

    - *
  • Cache statistics: hits, lookups, evictions, size - *
  • Handler metrics: request counts, error counts, timeouts - *
  • Index statistics: document counts, segment information - *
- * - * @param response the NamedList containing the data to extract from - * @param key the key to look up in the NamedList - * @return the Long value if found and convertible, null otherwise - * @see Number#longValue() - * @see Long#parseLong(String) - */ - public static Long getLong(NamedList response, String key) { - Object value = response.get(key); - if (value == null) return null; + /** + * Extracts a Long value from a NamedList using the specified key with robust + * type conversion. + * + *

+ * This method provides flexible extraction of Long values from Solr NamedList + * responses, handling various input formats that may be returned by different + * Solr endpoints. It performs safe type conversion with appropriate error + * handling for malformed data. + * + *

+ * Supported Input Types: + * + *

    + *
  • Number instances: Integer, Long, Double, Float, + * BigInteger, BigDecimal + *
  • String representations: Numeric strings that can be + * parsed as Long + *
  • Null values: Returns null without throwing exceptions + *
+ * + *

+ * Error Handling: + * + *

+ * Returns {@code null} for missing keys, null values, or unparseable strings + * rather than throwing exceptions, enabling graceful degradation in metrics + * collection scenarios. + * + *

+ * Common Use Cases: + * + *

    + *
  • Cache statistics: hits, lookups, evictions, size + *
  • Handler metrics: request counts, error counts, timeouts + *
  • Index statistics: document counts, segment information + *
+ * + * @param response + * the NamedList containing the data to extract from + * @param key + * the key to look up in the NamedList + * @return the Long value if found and convertible, null otherwise + * @see Number#longValue() + * @see Long#parseLong(String) + */ + public static Long getLong(NamedList response, String key) { + Object value = response.get(key); + if (value == null) + return null; - if (value instanceof Number number) { - return number.longValue(); - } + if (value instanceof Number number) { + return number.longValue(); + } - try { - return Long.parseLong(value.toString()); - } catch (NumberFormatException e) { - return null; - } - } + try { + return Long.parseLong(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } - /** - * Extracts a Float value from a NamedList using the specified key with automatic type - * conversion. - * - *

This method provides convenient extraction of Float values from Solr NamedList responses, - * commonly used for extracting percentage values, ratios, and performance metrics. It assumes - * that missing values should be treated as zero, which is appropriate for most metric - * scenarios. - * - *

Type Conversion: - * - *

Automatically converts any Number instance to Float using the {@link Number#floatValue()} - * method, ensuring compatibility with various numeric types returned by Solr. - * - *

Default Value Behavior: - * - *

Returns {@code 0.0f} for missing or null values, which is typically the desired behavior - * for metrics like hit ratios, performance averages, and statistical calculations where missing - * data should be interpreted as zero. - * - *

Common Use Cases: - * - *

    - *
  • Cache hit ratios and performance percentages - *
  • Average response times and throughput metrics - *
  • Statistical calculations and performance indicators - *
- * - *

Note: - * - *

This method differs from {@link #getLong(NamedList, String)} by returning a default value - * instead of null, which is more appropriate for Float metrics that represent rates, ratios, or - * averages. - * - * @param stats the NamedList containing the metric data to extract from - * @param key the key to look up in the NamedList - * @return the Float value if found, or 0.0f if the key doesn't exist or value is null - * @see Number#floatValue() - */ - public static Float getFloat(NamedList stats, String key) { - Object value = stats.get(key); - return value != null ? ((Number) value).floatValue() : 0.0f; - } + /** + * Extracts a Float value from a NamedList using the specified key with + * automatic type conversion. + * + *

+ * This method provides convenient extraction of Float values from Solr + * NamedList responses, commonly used for extracting percentage values, ratios, + * and performance metrics. It assumes that missing values should be treated as + * zero, which is appropriate for most metric scenarios. + * + *

+ * Type Conversion: + * + *

+ * Automatically converts any Number instance to Float using the + * {@link Number#floatValue()} method, ensuring compatibility with various + * numeric types returned by Solr. + * + *

+ * Default Value Behavior: + * + *

+ * Returns {@code 0.0f} for missing or null values, which is typically the + * desired behavior for metrics like hit ratios, performance averages, and + * statistical calculations where missing data should be interpreted as zero. + * + *

+ * Common Use Cases: + * + *

    + *
  • Cache hit ratios and performance percentages + *
  • Average response times and throughput metrics + *
  • Statistical calculations and performance indicators + *
+ * + *

+ * Note: + * + *

+ * This method differs from {@link #getLong(NamedList, String)} by returning a + * default value instead of null, which is more appropriate for Float metrics + * that represent rates, ratios, or averages. + * + * @param stats + * the NamedList containing the metric data to extract from + * @param key + * the key to look up in the NamedList + * @return the Float value if found, or 0.0f if the key doesn't exist or value + * is null + * @see Number#floatValue() + */ + public static Float getFloat(NamedList stats, String key) { + Object value = stats.get(key); + return value != null ? ((Number) value).floatValue() : 0.0f; + } - /** - * Extracts an Integer value from a NamedList using the specified key with robust type - * conversion. - * - *

This method provides flexible extraction of Integer values from Solr NamedList responses, - * handling various input formats that may be returned by different Solr endpoints. It performs - * safe type conversion with appropriate error handling for malformed data. - * - *

Supported Input Types: - * - *

    - *
  • Number instances: Integer, Long, Double, Float, BigInteger, BigDecimal - *
  • String representations: Numeric strings that can be parsed as Integer - *
  • Null values: Returns null without throwing exceptions - *
- * - *

Type Conversion Strategy: - * - *

For Number instances, uses {@link Number#intValue()} which truncates decimal values. For - * string values, attempts parsing with {@link Integer#parseInt(String)} and returns null if - * parsing fails rather than throwing an exception. - * - *

Error Handling: - * - *

Returns {@code null} for missing keys, null values, or unparseable strings rather than - * throwing exceptions, enabling graceful degradation in metrics collection scenarios. - * - *

Common Use Cases: - * - *

    - *
  • Index segment counts and document counts (when within Integer range) - *
  • Configuration values and small numeric metrics - *
  • Count-based statistics that don't exceed Integer.MAX_VALUE - *
- * - *

Range Considerations: - * - *

For large values that may exceed Integer range, consider using {@link #getLong(NamedList, - * String)} instead to avoid truncation or overflow issues. - * - * @param response the NamedList containing the data to extract from - * @param key the key to look up in the NamedList - * @return the Integer value if found and convertible, null otherwise - * @see Number#intValue() - * @see Integer#parseInt(String) - * @see #getLong(NamedList, String) - */ - public static Integer getInteger(NamedList response, String key) { - Object value = response.get(key); - if (value == null) return null; + /** + * Extracts an Integer value from a NamedList using the specified key with + * robust type conversion. + * + *

+ * This method provides flexible extraction of Integer values from Solr + * NamedList responses, handling various input formats that may be returned by + * different Solr endpoints. It performs safe type conversion with appropriate + * error handling for malformed data. + * + *

+ * Supported Input Types: + * + *

    + *
  • Number instances: Integer, Long, Double, Float, + * BigInteger, BigDecimal + *
  • String representations: Numeric strings that can be + * parsed as Integer + *
  • Null values: Returns null without throwing exceptions + *
+ * + *

+ * Type Conversion Strategy: + * + *

+ * For Number instances, uses {@link Number#intValue()} which truncates decimal + * values. For string values, attempts parsing with + * {@link Integer#parseInt(String)} and returns null if parsing fails rather + * than throwing an exception. + * + *

+ * Error Handling: + * + *

+ * Returns {@code null} for missing keys, null values, or unparseable strings + * rather than throwing exceptions, enabling graceful degradation in metrics + * collection scenarios. + * + *

+ * Common Use Cases: + * + *

    + *
  • Index segment counts and document counts (when within Integer range) + *
  • Configuration values and small numeric metrics + *
  • Count-based statistics that don't exceed Integer.MAX_VALUE + *
+ * + *

+ * Range Considerations: + * + *

+ * For large values that may exceed Integer range, consider using + * {@link #getLong(NamedList, String)} instead to avoid truncation or overflow + * issues. + * + * @param response + * the NamedList containing the data to extract from + * @param key + * the key to look up in the NamedList + * @return the Integer value if found and convertible, null otherwise + * @see Number#intValue() + * @see Integer#parseInt(String) + * @see #getLong(NamedList, String) + */ + public static Integer getInteger(NamedList response, String key) { + Object value = response.get(key); + if (value == null) + return null; - if (value instanceof Number number) { - return number.intValue(); - } + if (value instanceof Number number) { + return number.intValue(); + } - try { - return Integer.parseInt(value.toString()); - } catch (NumberFormatException e) { - return null; - } - } + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java b/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java index 7d1fbf7..be00323 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java @@ -45,19 +45,27 @@ /** * Top-level container for comprehensive Solr collection metrics. * - *

This class aggregates various types of Solr performance and operational metrics including - * index statistics, query performance, cache utilization, and request handler metrics. It serves as - * the primary response object for collection monitoring and analysis tools. + *

+ * This class aggregates various types of Solr performance and operational + * metrics including index statistics, query performance, cache utilization, and + * request handler metrics. It serves as the primary response object for + * collection monitoring and analysis tools. * - *

The metrics are collected from multiple Solr admin endpoints and MBeans to provide a - * comprehensive view of collection health and performance characteristics. + *

+ * The metrics are collected from multiple Solr admin endpoints and MBeans to + * provide a comprehensive view of collection health and performance + * characteristics. * - *

Null-Safe Design: + *

+ * Null-Safe Design: * - *

Individual metric components (cache stats, handler stats) may be null if the corresponding - * data is unavailable or empty. Always check for null values before accessing nested properties. + *

+ * Individual metric components (cache stats, handler stats) may be null if the + * corresponding data is unavailable or empty. Always check for null values + * before accessing nested properties. * - *

Example usage: + *

+ * Example usage: * *

{@code
  * SolrMetrics metrics = collectionService.getCollectionStats("my_collection");
@@ -65,7 +73,7 @@
  *
  * // Safe null checking for optional metrics
  * if (metrics.getCacheStats() != null && metrics.getCacheStats().getQueryResultCache() != null) {
- *     System.out.println("Cache hit ratio: " + metrics.getCacheStats().getQueryResultCache().getHitratio());
+ * 	System.out.println("Cache hit ratio: " + metrics.getCacheStats().getQueryResultCache().getHitratio());
  * }
  * }
* @@ -77,330 +85,395 @@ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record SolrMetrics( - /** Index-related statistics including document counts and segment information */ - IndexStats indexStats, - - /** Query performance metrics from the most recent search operations */ - QueryStats queryStats, - - /** - * Cache utilization statistics for query result, document, and filter caches (may be null) - */ - CacheStats cacheStats, - - /** Request handler performance metrics for select and update operations (may be null) */ - HandlerStats handlerStats, - - /** Timestamp when these metrics were collected, formatted as ISO 8601 */ - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - Date timestamp) {} + /** + * Index-related statistics including document counts and segment information + */ + IndexStats indexStats, + + /** Query performance metrics from the most recent search operations */ + QueryStats queryStats, + + /** + * Cache utilization statistics for query result, document, and filter caches + * (may be null) + */ + CacheStats cacheStats, + + /** + * Request handler performance metrics for select and update operations (may be + * null) + */ + HandlerStats handlerStats, + + /** Timestamp when these metrics were collected, formatted as ISO 8601 */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { +} /** * Lucene index statistics for a Solr collection. * - *

Provides essential information about the underlying Lucene index structure and document - * composition. These metrics are retrieved using Solr's Luke request handler which exposes - * Lucene-level index information. + *

+ * Provides essential information about the underlying Lucene index structure + * and document composition. These metrics are retrieved using Solr's Luke + * request handler which exposes Lucene-level index information. * - *

Available Metrics: + *

+ * Available Metrics: * *

    - *
  • numDocs: Total number of documents excluding deleted documents - *
  • segmentCount: Number of Lucene segments (affects search performance) + *
  • numDocs: Total number of documents excluding deleted + * documents + *
  • segmentCount: Number of Lucene segments (affects search + * performance) *
* - *

Performance Implications: + *

+ * Performance Implications: * - *

High segment counts may indicate the need for index optimization to improve search - * performance. The optimal segment count depends on index size and update frequency. + *

+ * High segment counts may indicate the need for index optimization to improve + * search performance. The optimal segment count depends on index size and + * update frequency. * * @see org.apache.solr.client.solrj.request.LukeRequest */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record IndexStats( - /** Total number of documents in the index (excluding deleted documents) */ - Integer numDocs, + /** Total number of documents in the index (excluding deleted documents) */ + Integer numDocs, - /** - * Number of Lucene segments in the index (lower numbers generally indicate better - * performance) - */ - Integer segmentCount) {} + /** + * Number of Lucene segments in the index (lower numbers generally indicate + * better performance) + */ + Integer segmentCount) { +} /** * Field-level statistics for individual Solr schema fields. * - *

Provides detailed information about how individual fields are utilized within the Solr index. - * This information helps with schema optimization and understanding field usage patterns. + *

+ * Provides detailed information about how individual fields are utilized within + * the Solr index. This information helps with schema optimization and + * understanding field usage patterns. * - *

Statistics include: + *

+ * Statistics include: * *

    - *
  • type: Solr field type (e.g., "text_general", "int", "date") - *
  • docs: Number of documents containing this field - *
  • distinct: Number of unique values for this field + *
  • type: Solr field type (e.g., "text_general", "int", + * "date") + *
  • docs: Number of documents containing this field + *
  • distinct: Number of unique values for this field *
* - *

Analysis Insights: + *

+ * Analysis Insights: * - *

High cardinality fields (high distinct values) may require special indexing considerations, - * while sparsely populated fields (low docs count) might benefit from different storage strategies. + *

+ * High cardinality fields (high distinct values) may require special indexing + * considerations, while sparsely populated fields (low docs count) might + * benefit from different storage strategies. * - *

Note: This class is currently unused in the collection statistics but is - * available for future field-level analysis features. + *

+ * Note: This class is currently unused in the collection + * statistics but is available for future field-level analysis features. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record FieldStats( - /** Solr field type as defined in the schema configuration */ - String type, + /** Solr field type as defined in the schema configuration */ + String type, - /** Number of documents in the index that contain this field */ - Integer docs, + /** Number of documents in the index that contain this field */ + Integer docs, - /** Number of unique/distinct values for this field across all documents */ - Integer distinct) {} + /** Number of unique/distinct values for this field across all documents */ + Integer distinct) { +} /** * Query execution performance metrics from Solr search operations. * - *

Captures performance characteristics and result metadata from the most recent query execution. - * These metrics help identify query performance patterns and potential optimization opportunities. + *

+ * Captures performance characteristics and result metadata from the most recent + * query execution. These metrics help identify query performance patterns and + * potential optimization opportunities. * - *

Available Metrics: + *

+ * Available Metrics: * *

    - *
  • queryTime: Time in milliseconds to execute the query - *
  • totalResults: Total number of matching documents found - *
  • start: Starting offset for pagination - *
  • maxScore: Highest relevance score in the result set + *
  • queryTime: Time in milliseconds to execute the query + *
  • totalResults: Total number of matching documents found + *
  • start: Starting offset for pagination + *
  • maxScore: Highest relevance score in the result set *
* - *

Performance Analysis: + *

+ * Performance Analysis: * - *

Query time metrics help identify slow queries that may need optimization, while result counts - * and scores provide insight into search effectiveness and relevance tuning needs. + *

+ * Query time metrics help identify slow queries that may need optimization, + * while result counts and scores provide insight into search effectiveness and + * relevance tuning needs. * * @see org.apache.solr.client.solrj.response.QueryResponse */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record QueryStats( - /** Time in milliseconds required to execute the most recent query */ - Integer queryTime, + /** Time in milliseconds required to execute the most recent query */ + Integer queryTime, - /** Total number of documents matching the query criteria */ - Long totalResults, + /** Total number of documents matching the query criteria */ + Long totalResults, - /** Starting position for paginated results (0-based offset) */ - Long start, + /** Starting position for paginated results (0-based offset) */ + Long start, - /** Highest relevance score among the returned documents */ - Float maxScore) {} + /** Highest relevance score among the returned documents */ + Float maxScore) { +} /** * Solr cache utilization statistics across all cache types. * - *

Aggregates cache performance metrics for the three primary Solr caches. Cache performance - * directly impacts query response times and system resource utilization, making these metrics - * critical for performance tuning. + *

+ * Aggregates cache performance metrics for the three primary Solr caches. Cache + * performance directly impacts query response times and system resource + * utilization, making these metrics critical for performance tuning. * - *

Monitored Cache Types: + *

+ * Monitored Cache Types: * *

    - *
  • queryResultCache: Caches complete query results - *
  • documentCache: Caches retrieved document data - *
  • filterCache: Caches filter query results + *
  • queryResultCache: Caches complete query results + *
  • documentCache: Caches retrieved document data + *
  • filterCache: Caches filter query results *
* - *

Cache Analysis: + *

+ * Cache Analysis: * - *

Poor cache hit ratios may indicate undersized caches or query patterns that don't benefit from - * caching. Cache evictions suggest memory pressure or cache size optimization needs. + *

+ * Poor cache hit ratios may indicate undersized caches or query patterns that + * don't benefit from caching. Cache evictions suggest memory pressure or cache + * size optimization needs. * * @see CacheInfo */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record CacheStats( - /** Performance metrics for the query result cache */ - CacheInfo queryResultCache, + /** Performance metrics for the query result cache */ + CacheInfo queryResultCache, - /** Performance metrics for the document cache */ - CacheInfo documentCache, + /** Performance metrics for the document cache */ + CacheInfo documentCache, - /** Performance metrics for the filter cache */ - CacheInfo filterCache) {} + /** Performance metrics for the filter cache */ + CacheInfo filterCache) { +} /** * Detailed performance metrics for individual Solr cache instances. * - *

Provides comprehensive cache utilization statistics including hit ratios, eviction rates, and - * current size metrics. These metrics are essential for cache tuning and memory management - * optimization. + *

+ * Provides comprehensive cache utilization statistics including hit ratios, + * eviction rates, and current size metrics. These metrics are essential for + * cache tuning and memory management optimization. * - *

Key Performance Indicators: + *

+ * Key Performance Indicators: * *

    - *
  • hitratio: Cache effectiveness (higher is better) - *
  • evictions: Memory pressure indicator - *
  • size: Current cache utilization - *
  • lookups vs hits: Cache request patterns + *
  • hitratio: Cache effectiveness (higher is better) + *
  • evictions: Memory pressure indicator + *
  • size: Current cache utilization + *
  • lookups vs hits: Cache request patterns *
* - *

Performance Targets: + *

+ * Performance Targets: * - *

Optimal cache performance typically shows high hit ratios (>0.80) with minimal evictions. High - * eviction rates suggest cache size increases may improve performance. + *

+ * Optimal cache performance typically shows high hit ratios (>0.80) with + * minimal evictions. High eviction rates suggest cache size increases may + * improve performance. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record CacheInfo( - /** Total number of cache lookup requests */ - Long lookups, + /** Total number of cache lookup requests */ + Long lookups, - /** Number of successful cache hits */ - Long hits, + /** Number of successful cache hits */ + Long hits, - /** Cache hit ratio (hits/lookups) - higher values indicate better cache performance */ - Float hitratio, + /** + * Cache hit ratio (hits/lookups) - higher values indicate better cache + * performance + */ + Float hitratio, - /** Number of new entries added to the cache */ - Long inserts, + /** Number of new entries added to the cache */ + Long inserts, - /** Number of entries removed due to cache size limits (indicates memory pressure) */ - Long evictions, + /** + * Number of entries removed due to cache size limits (indicates memory + * pressure) + */ + Long evictions, - /** Current number of entries stored in the cache */ - Long size) {} + /** Current number of entries stored in the cache */ + Long size) { +} /** * Request handler performance statistics for core Solr operations. * - *

Tracks performance metrics for the primary Solr request handlers that process search and - * update operations. Handler performance directly affects user experience and system throughput - * capacity. + *

+ * Tracks performance metrics for the primary Solr request handlers that process + * search and update operations. Handler performance directly affects user + * experience and system throughput capacity. * - *

Monitored Handlers: + *

+ * Monitored Handlers: * *

    - *
  • selectHandler: Processes search/query requests (/select) - *
  • updateHandler: Processes document indexing requests (/update) + *
  • selectHandler: Processes search/query requests (/select) + *
  • updateHandler: Processes document indexing requests + * (/update) *
* - *

Performance Analysis: + *

+ * Performance Analysis: * - *

Handler metrics help identify bottlenecks in request processing and guide capacity planning - * decisions. High error rates or response times indicate potential optimization needs. + *

+ * Handler metrics help identify bottlenecks in request processing and guide + * capacity planning decisions. High error rates or response times indicate + * potential optimization needs. * * @see HandlerInfo */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record HandlerStats( - /** Performance metrics for the search/select request handler */ - HandlerInfo selectHandler, + /** Performance metrics for the search/select request handler */ + HandlerInfo selectHandler, - /** Performance metrics for the document update request handler */ - HandlerInfo updateHandler) {} + /** Performance metrics for the document update request handler */ + HandlerInfo updateHandler) { +} /** * Detailed performance metrics for individual Solr request handlers. * - *

Provides comprehensive request handler statistics including throughput, error rates, and - * performance characteristics. These metrics are crucial for identifying performance bottlenecks - * and system reliability issues. + *

+ * Provides comprehensive request handler statistics including throughput, error + * rates, and performance characteristics. These metrics are crucial for + * identifying performance bottlenecks and system reliability issues. * - *

Performance Metrics: + *

+ * Performance Metrics: * *

    - *
  • requests: Total volume processed - *
  • errors: Reliability indicator - *
  • avgTimePerRequest: Response time performance - *
  • avgRequestsPerSecond: Throughput capacity + *
  • requests: Total volume processed + *
  • errors: Reliability indicator + *
  • avgTimePerRequest: Response time performance + *
  • avgRequestsPerSecond: Throughput capacity *
* - *

Health Indicators: + *

+ * Health Indicators: * - *

High error rates may indicate system stress or configuration issues. Increasing response times - * suggest capacity limits or optimization needs. + *

+ * High error rates may indicate system stress or configuration issues. + * Increasing response times suggest capacity limits or optimization needs. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record HandlerInfo( - /** Total number of requests processed by this handler */ - Long requests, + /** Total number of requests processed by this handler */ + Long requests, - /** Number of requests that resulted in errors */ - Long errors, + /** Number of requests that resulted in errors */ + Long errors, - /** Number of requests that exceeded timeout limits */ - Long timeouts, + /** Number of requests that exceeded timeout limits */ + Long timeouts, - /** Cumulative time spent processing all requests (milliseconds) */ - Long totalTime, + /** Cumulative time spent processing all requests (milliseconds) */ + Long totalTime, - /** Average time per request in milliseconds */ - Float avgTimePerRequest, + /** Average time per request in milliseconds */ + Float avgTimePerRequest, - /** Average throughput in requests per second */ - Float avgRequestsPerSecond) {} + /** Average throughput in requests per second */ + Float avgRequestsPerSecond) { +} /** * Comprehensive health status assessment for Solr collections. * - *

Provides a complete health check result including availability status, performance metrics, - * and diagnostic information. This serves as a primary monitoring endpoint for collection - * operational status. + *

+ * Provides a complete health check result including availability status, + * performance metrics, and diagnostic information. This serves as a primary + * monitoring endpoint for collection operational status. * - *

Health Assessment Components: + *

+ * Health Assessment Components: * *

    - *
  • isHealthy: Overall collection availability - *
  • responseTime: Performance indicator - *
  • totalDocuments: Content availability - *
  • errorMessage: Diagnostic information when unhealthy + *
  • isHealthy: Overall collection availability + *
  • responseTime: Performance indicator + *
  • totalDocuments: Content availability + *
  • errorMessage: Diagnostic information when unhealthy *
* - *

Monitoring Integration: + *

+ * Monitoring Integration: * - *

This DTO is typically used by monitoring systems and dashboards to provide real-time - * collection health status and enable automated alerting on failures. + *

+ * This DTO is typically used by monitoring systems and dashboards to provide + * real-time collection health status and enable automated alerting on failures. * - *

Example usage: + *

+ * Example usage: * *

{@code
  * SolrHealthStatus status = collectionService.checkHealth("my_collection");
  * if (!status.isHealthy()) {
- *     logger.error("Collection unhealthy: " + status.getErrorMessage());
+ * 	logger.error("Collection unhealthy: " + status.getErrorMessage());
  * }
  * }
*/ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record SolrHealthStatus( - /** Overall health status - true if collection is operational and responding */ - boolean isHealthy, + /** Overall health status - true if collection is operational and responding */ + boolean isHealthy, - /** Detailed error message when isHealthy is false, null when healthy */ - String errorMessage, + /** Detailed error message when isHealthy is false, null when healthy */ + String errorMessage, - /** Response time in milliseconds for the health check ping request */ - Long responseTime, + /** Response time in milliseconds for the health check ping request */ + Long responseTime, - /** Total number of documents currently indexed in the collection */ - Long totalDocuments, + /** Total number of documents currently indexed in the collection */ + Long totalDocuments, - /** Timestamp when this health check was performed, formatted as ISO 8601 */ - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - Date lastChecked, + /** Timestamp when this health check was performed, formatted as ISO 8601 */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date lastChecked, - /** Name of the collection that was checked */ - String collection, + /** Name of the collection that was checked */ + String collection, - /** Version of Solr server (when available) */ - String solrVersion, + /** Version of Solr server (when available) */ + String solrVersion, - /** Additional status information or state description */ - String status) {} + /** Additional status information or state description */ + String status) { +} diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java index 72ac9b0..ea131f4 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java @@ -23,54 +23,77 @@ import org.springframework.stereotype.Service; /** - * Spring Service providing schema introspection and management capabilities for Apache Solr - * collections. + * Spring Service providing schema introspection and management capabilities for + * Apache Solr collections. * - *

This service enables exploration and analysis of Solr collection schemas through the Model - * Context Protocol (MCP), allowing AI clients to understand field definitions, data types, and - * schema configuration for intelligent query construction and data analysis workflows. + *

+ * This service enables exploration and analysis of Solr collection schemas + * through the Model Context Protocol (MCP), allowing AI clients to understand + * field definitions, data types, and schema configuration for intelligent query + * construction and data analysis workflows. * - *

Core Capabilities: + *

+ * Core Capabilities: * *

    - *
  • Schema Retrieval: Complete schema information for any collection - *
  • Field Introspection: Detailed field type and configuration analysis - *
  • Dynamic Field Support: Discovery of dynamic field patterns and rules - *
  • Copy Field Analysis: Understanding of field copying and aggregation rules + *
  • Schema Retrieval: Complete schema information for any + * collection + *
  • Field Introspection: Detailed field type and + * configuration analysis + *
  • Dynamic Field Support: Discovery of dynamic field + * patterns and rules + *
  • Copy Field Analysis: Understanding of field copying and + * aggregation rules *
* - *

Schema Information Provided: + *

+ * Schema Information Provided: * *

    - *
  • Field Definitions: Names, types, indexing, and storage configurations - *
  • Field Types: Analyzer configurations, tokenization, and filtering rules - *
  • Dynamic Fields: Pattern-based field matching and type assignment - *
  • Copy Fields: Source-to-destination field copying configurations - *
  • Unique Key: Primary key field identification and configuration + *
  • Field Definitions: Names, types, indexing, and storage + * configurations + *
  • Field Types: Analyzer configurations, tokenization, and + * filtering rules + *
  • Dynamic Fields: Pattern-based field matching and type + * assignment + *
  • Copy Fields: Source-to-destination field copying + * configurations + *
  • Unique Key: Primary key field identification and + * configuration *
* - *

MCP Tool Integration: + *

+ * MCP Tool Integration: * - *

Schema operations are exposed as MCP tools that AI clients can invoke through natural language - * requests such as "show me the schema for my_collection" or "what fields are available for - * searching in the products index". + *

+ * Schema operations are exposed as MCP tools that AI clients can invoke through + * natural language requests such as "show me the schema for my_collection" or + * "what fields are available for searching in the products index". * - *

Use Cases: + *

+ * Use Cases: * *

    - *
  • Query Planning: Understanding available fields for search construction - *
  • Data Analysis: Identifying field types and capabilities for analytics - *
  • Index Optimization: Analyzing field configurations for performance tuning - *
  • Schema Documentation: Generating documentation from live schema - * definitions + *
  • Query Planning: Understanding available fields for + * search construction + *
  • Data Analysis: Identifying field types and capabilities + * for analytics + *
  • Index Optimization: Analyzing field configurations for + * performance tuning + *
  • Schema Documentation: Generating documentation from live + * schema definitions *
* - *

Integration with Other Services: + *

+ * Integration with Other Services: * - *

Schema information complements other MCP services by providing the metadata necessary for - * intelligent search query construction, field validation, and result interpretation. + *

+ * Schema information complements other MCP services by providing the metadata + * necessary for intelligent search query construction, field validation, and + * result interpretation. * - *

Example Usage: + *

+ * Example Usage: * *

{@code
  * // Get complete schema information
@@ -78,12 +101,12 @@
  *
  * // Analyze field configurations
  * schema.getFields().forEach(field -> {
- *     System.out.println("Field: " + field.getName() + " Type: " + field.getType());
+ * 	System.out.println("Field: " + field.getName() + " Type: " + field.getType());
  * });
  *
  * // Examine dynamic field patterns
  * schema.getDynamicFields().forEach(dynField -> {
- *     System.out.println("Pattern: " + dynField.getName() + " Type: " + dynField.getType());
+ * 	System.out.println("Pattern: " + dynField.getName() + " Type: " + dynField.getType());
  * });
  * }
* @@ -96,87 +119,107 @@ @Service public class SchemaService { - /** SolrJ client for communicating with Solr server */ - private final SolrClient solrClient; + /** SolrJ client for communicating with Solr server */ + private final SolrClient solrClient; - /** - * Constructs a new SchemaService with the required SolrClient dependency. - * - *

This constructor is automatically called by Spring's dependency injection framework during - * application startup, providing the service with the necessary Solr client for schema - * operations. - * - * @param solrClient the SolrJ client instance for communicating with Solr - * @see SolrClient - */ - public SchemaService(SolrClient solrClient) { - this.solrClient = solrClient; - } + /** + * Constructs a new SchemaService with the required SolrClient dependency. + * + *

+ * This constructor is automatically called by Spring's dependency injection + * framework during application startup, providing the service with the + * necessary Solr client for schema operations. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + * @see SolrClient + */ + public SchemaService(SolrClient solrClient) { + this.solrClient = solrClient; + } - /** - * Retrieves the complete schema definition for a specified Solr collection. - * - *

This method provides comprehensive access to all schema components including field - * definitions, field types, dynamic fields, copy fields, and schema-level configuration. The - * returned schema representation contains all information necessary for understanding the - * collection's data structure and capabilities. - * - *

Schema Components Included: - * - *

    - *
  • Fields: Static field definitions with types and properties - *
  • Field Types: Analyzer configurations and processing rules - *
  • Dynamic Fields: Pattern-based field matching rules - *
  • Copy Fields: Field copying and aggregation configurations - *
  • Unique Key: Primary key field specification - *
  • Schema Attributes: Version, name, and global settings - *
- * - *

Field Information Details: - * - *

Each field definition includes comprehensive metadata: - * - *

    - *
  • Name: Field identifier for queries and indexing - *
  • Type: Reference to field type configuration - *
  • Indexed: Whether the field is searchable - *
  • Stored: Whether field values are retrievable - *
  • Multi-valued: Whether multiple values are allowed - *
  • Required: Whether the field must have a value - *
- * - *

MCP Tool Usage: - * - *

AI clients can invoke this method with natural language requests such as: - * - *

    - *
  • "Show me the schema for the products collection" - *
  • "What fields are available in my_index?" - *
  • "Get the field definitions for the search index" - *
- * - *

Error Handling: - * - *

If the collection does not exist or schema retrieval fails, the method will throw an - * exception with details about the failure reason. Common issues include collection name typos, - * permission problems, or Solr connectivity issues. - * - *

Performance Considerations: - * - *

Schema information is typically cached by Solr and retrieval is generally fast. However, - * for applications that frequently access schema information, consider implementing client-side - * caching to reduce network overhead. - * - * @param collection the name of the Solr collection to retrieve schema information for - * @return complete schema representation containing all field and type definitions - * @throws Exception if collection does not exist, access is denied, or communication fails - * @see SchemaRepresentation - * @see SchemaRequest - * @see org.apache.solr.client.solrj.response.schema.SchemaResponse - */ - @McpTool(description = "Get schema for a Solr collection") - public SchemaRepresentation getSchema(String collection) throws Exception { - SchemaRequest schemaRequest = new SchemaRequest(); - return schemaRequest.process(solrClient, collection).getSchemaRepresentation(); - } + /** + * Retrieves the complete schema definition for a specified Solr collection. + * + *

+ * This method provides comprehensive access to all schema components including + * field definitions, field types, dynamic fields, copy fields, and schema-level + * configuration. The returned schema representation contains all information + * necessary for understanding the collection's data structure and capabilities. + * + *

+ * Schema Components Included: + * + *

    + *
  • Fields: Static field definitions with types and + * properties + *
  • Field Types: Analyzer configurations and processing + * rules + *
  • Dynamic Fields: Pattern-based field matching rules + *
  • Copy Fields: Field copying and aggregation + * configurations + *
  • Unique Key: Primary key field specification + *
  • Schema Attributes: Version, name, and global settings + *
+ * + *

+ * Field Information Details: + * + *

+ * Each field definition includes comprehensive metadata: + * + *

    + *
  • Name: Field identifier for queries and indexing + *
  • Type: Reference to field type configuration + *
  • Indexed: Whether the field is searchable + *
  • Stored: Whether field values are retrievable + *
  • Multi-valued: Whether multiple values are allowed + *
  • Required: Whether the field must have a value + *
+ * + *

+ * MCP Tool Usage: + * + *

+ * AI clients can invoke this method with natural language requests such as: + * + *

    + *
  • "Show me the schema for the products collection" + *
  • "What fields are available in my_index?" + *
  • "Get the field definitions for the search index" + *
+ * + *

+ * Error Handling: + * + *

+ * If the collection does not exist or schema retrieval fails, the method will + * throw an exception with details about the failure reason. Common issues + * include collection name typos, permission problems, or Solr connectivity + * issues. + * + *

+ * Performance Considerations: + * + *

+ * Schema information is typically cached by Solr and retrieval is generally + * fast. However, for applications that frequently access schema information, + * consider implementing client-side caching to reduce network overhead. + * + * @param collection + * the name of the Solr collection to retrieve schema information for + * @return complete schema representation containing all field and type + * definitions + * @throws Exception + * if collection does not exist, access is denied, or communication + * fails + * @see SchemaRepresentation + * @see SchemaRequest + * @see org.apache.solr.client.solrj.response.schema.SchemaResponse + */ + @McpTool(description = "Get schema for a Solr collection") + public SchemaRepresentation getSchema(String collection) throws Exception { + SchemaRequest schemaRequest = new SchemaRequest(); + return schemaRequest.process(solrClient, collection).getSchemaRepresentation(); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java b/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java index 3dc4e03..b84f4cf 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java @@ -20,48 +20,65 @@ import java.util.Map; /** - * Immutable record representing a structured search response from Apache Solr operations. + * Immutable record representing a structured search response from Apache Solr + * operations. * - *

This record encapsulates all essential components of a Solr search result in a type-safe, - * immutable structure that can be easily serialized to JSON for MCP client consumption. It provides - * a clean abstraction over Solr's native response format while preserving all critical search - * metadata and result data. + *

+ * This record encapsulates all essential components of a Solr search result in + * a type-safe, immutable structure that can be easily serialized to JSON for + * MCP client consumption. It provides a clean abstraction over Solr's native + * response format while preserving all critical search metadata and result + * data. * - *

Record Benefits: + *

+ * Record Benefits: * *

    - *
  • Immutability: Response data cannot be modified after creation - *
  • Type Safety: Compile-time validation of response structure - *
  • JSON Serialization: Automatic conversion to JSON for MCP clients - *
  • Memory Efficiency: Compact representation with minimal overhead + *
  • Immutability: Response data cannot be modified after + * creation + *
  • Type Safety: Compile-time validation of response + * structure + *
  • JSON Serialization: Automatic conversion to JSON for MCP + * clients + *
  • Memory Efficiency: Compact representation with minimal + * overhead *
* - *

Search Metadata: + *

+ * Search Metadata: * - *

The response includes comprehensive search metadata that helps clients understand the query - * results and implement pagination, relevance analysis, and user interfaces: + *

+ * The response includes comprehensive search metadata that helps clients + * understand the query results and implement pagination, relevance analysis, + * and user interfaces: * *

    - *
  • Total Results: Complete count of matching documents - *
  • Pagination Info: Current offset for result windowing - *
  • Relevance Scoring: Maximum relevance score in the result set + *
  • Total Results: Complete count of matching documents + *
  • Pagination Info: Current offset for result windowing + *
  • Relevance Scoring: Maximum relevance score in the result + * set *
* - *

Document Structure: + *

+ * Document Structure: * - *

Documents are represented as flexible key-value maps to accommodate Solr's dynamic field - * capabilities and schema-less operation. Each document map contains field names as keys and field - * values as objects, preserving the original data types from Solr (strings, numbers, dates, arrays, - * etc.). + *

+ * Documents are represented as flexible key-value maps to accommodate Solr's + * dynamic field capabilities and schema-less operation. Each document map + * contains field names as keys and field values as objects, preserving the + * original data types from Solr (strings, numbers, dates, arrays, etc.). * - *

Faceting Support: + *

+ * Faceting Support: * - *

Facet information is structured as a nested map hierarchy where the outer map represents facet - * field names and inner maps contain facet values with their corresponding document counts. This - * structure efficiently supports multiple faceting strategies including field faceting and range - * faceting. + *

+ * Facet information is structured as a nested map hierarchy where the outer map + * represents facet field names and inner maps contain facet values with their + * corresponding document counts. This structure efficiently supports multiple + * faceting strategies including field faceting and range faceting. * - *

Usage Examples: + *

+ * Usage Examples: * *

{@code
  * // Access search results
@@ -70,17 +87,17 @@
  *
  * // Iterate through documents
  * for (Map doc : response.documents()) {
- *     System.out.println("Title: " + doc.get("title"));
- *     System.out.println("Price: " + doc.get("price"));
+ * 	System.out.println("Title: " + doc.get("title"));
+ * 	System.out.println("Price: " + doc.get("price"));
  * }
  *
  * // Access facet data
  * Map categoryFacets = response.facets().get("category");
- * categoryFacets.forEach((category, count) ->
- *     System.out.println(category + ": " + count + " items"));
+ * categoryFacets.forEach((category, count) -> System.out.println(category + ": " + count + " items"));
  * }
* - *

JSON Serialization Example: + *

+ * JSON Serialization Example: * *

{@code
  * {
@@ -98,20 +115,27 @@
  * }
  * }
* - * @param numFound total number of documents matching the search query across all pages - * @param start zero-based offset indicating the starting position of returned results - * @param maxScore highest relevance score among the returned documents (null if scoring disabled) - * @param documents list of document maps containing field names and values for each result - * @param facets nested map structure containing facet field names, values, and document counts + * @param numFound + * total number of documents matching the search query across all + * pages + * @param start + * zero-based offset indicating the starting position of returned + * results + * @param maxScore + * highest relevance score among the returned documents (null if + * scoring disabled) + * @param documents + * list of document maps containing field names and values for each + * result + * @param facets + * nested map structure containing facet field names, values, and + * document counts * @version 0.0.1 * @since 0.0.1 * @see SearchService#search(String, String, List, List, List, Integer, Integer) * @see org.apache.solr.client.solrj.response.QueryResponse * @see org.apache.solr.common.SolrDocumentList */ -public record SearchResponse( - long numFound, - long start, - Float maxScore, - List> documents, - Map> facets) {} +public record SearchResponse(long numFound, long start, Float maxScore, List> documents, + Map> facets) { +} diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java index 5805844..a7e82ae 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java @@ -37,47 +37,61 @@ import org.springframework.util.StringUtils; /** - * Spring Service providing comprehensive search capabilities for Apache Solr collections through - * Model Context Protocol (MCP) integration. + * Spring Service providing comprehensive search capabilities for Apache Solr + * collections through Model Context Protocol (MCP) integration. * - *

This service serves as the primary interface for executing search operations against Solr - * collections, offering a rich set of features including text search, filtering, faceting, sorting, - * and pagination. It transforms complex Solr query syntax into accessible MCP tools that AI clients - * can invoke through natural language requests. + *

+ * This service serves as the primary interface for executing search operations + * against Solr collections, offering a rich set of features including text + * search, filtering, faceting, sorting, and pagination. It transforms complex + * Solr query syntax into accessible MCP tools that AI clients can invoke + * through natural language requests. * - *

Core Features: + *

+ * Core Features: * *

    - *
  • Full-Text Search: Advanced text search with relevance scoring - *
  • Filtering: Multi-criteria filtering using Solr filter queries - *
  • Faceting: Dynamic facet generation for result categorization - *
  • Sorting: Flexible result ordering by multiple fields - *
  • Pagination: Efficient handling of large result sets + *
  • Full-Text Search: Advanced text search with relevance + * scoring + *
  • Filtering: Multi-criteria filtering using Solr filter + * queries + *
  • Faceting: Dynamic facet generation for result + * categorization + *
  • Sorting: Flexible result ordering by multiple fields + *
  • Pagination: Efficient handling of large result sets *
* - *

Dynamic Field Support: + *

+ * Dynamic Field Support: * - *

The service handles Solr's dynamic field naming conventions where field names include type - * suffixes that indicate data types and indexing behavior: + *

+ * The service handles Solr's dynamic field naming conventions where field names + * include type suffixes that indicate data types and indexing behavior: * *

    - *
  • _s: String fields for exact matching - *
  • _t: Text fields with tokenization and analysis - *
  • _i, _l, _f, _d: Numeric fields (int, long, float, double) - *
  • _dt: Date/time fields - *
  • _b: Boolean fields + *
  • _s: String fields for exact matching + *
  • _t: Text fields with tokenization and analysis + *
  • _i, _l, _f, _d: Numeric fields (int, long, float, + * double) + *
  • _dt: Date/time fields + *
  • _b: Boolean fields *
* - *

MCP Tool Integration: + *

+ * MCP Tool Integration: * - *

Search operations are exposed as MCP tools that AI clients can invoke through natural language - * requests such as "search for books by George R.R. Martin" or "find products under $50 in the - * electronics category". + *

+ * Search operations are exposed as MCP tools that AI clients can invoke through + * natural language requests such as "search for books by George R.R. Martin" or + * "find products under $50 in the electronics category". * - *

Response Format: + *

+ * Response Format: * - *

Returns structured {@link SearchResponse} objects that encapsulate search results, metadata, - * and facet information in a format optimized for JSON serialization and consumption by AI clients. + *

+ * Returns structured {@link SearchResponse} objects that encapsulate search + * results, metadata, and facet information in a format optimized for JSON + * serialization and consumption by AI clients. * * @version 0.0.1 * @since 0.0.1 @@ -88,204 +102,200 @@ @Service public class SearchService { - public static final String SORT_ITEM = "item"; - public static final String SORT_ORDER = "order"; - private final SolrClient solrClient; + public static final String SORT_ITEM = "item"; + public static final String SORT_ORDER = "order"; + private final SolrClient solrClient; - /** - * Constructs a new SearchService with the required SolrClient dependency. - * - *

This constructor is automatically called by Spring's dependency injection framework during - * application startup, providing the service with the necessary Solr client for executing - * search operations. - * - * @param solrClient the SolrJ client instance for communicating with Solr - * @see SolrClient - */ - public SearchService(SolrClient solrClient) { - this.solrClient = solrClient; - } + /** + * Constructs a new SearchService with the required SolrClient dependency. + * + *

+ * This constructor is automatically called by Spring's dependency injection + * framework during application startup, providing the service with the + * necessary Solr client for executing search operations. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + * @see SolrClient + */ + public SearchService(SolrClient solrClient) { + this.solrClient = solrClient; + } - /** - * Converts a SolrDocumentList to a List of Maps for optimized JSON serialization. - * - *

This method transforms Solr's native document format into a structure that can be easily - * serialized to JSON and consumed by MCP clients. Each document becomes a flat map of field - * names to field values, preserving all data types. - * - *

Conversion Process: - * - *

    - *
  • Iterates through each SolrDocument in the list - *
  • Extracts all field names and their corresponding values - *
  • Creates a HashMap for each document with field-value pairs - *
  • Preserves original data types (strings, numbers, dates, arrays) - *
- * - *

Performance Optimization: - * - *

Pre-allocates the ArrayList with the known document count to minimize memory allocations - * and improve conversion performance for large result sets. - * - * @param documents the SolrDocumentList to convert from Solr's native format - * @return a List of Maps where each Map represents a document with field names as keys - * @see SolrDocument - * @see SolrDocumentList - */ - private static List> getDocs(SolrDocumentList documents) { - List> docs = new ArrayList<>(documents.size()); - documents.forEach( - doc -> { - Map docMap = new HashMap<>(); - for (String fieldName : doc.getFieldNames()) { - docMap.put(fieldName, doc.getFieldValue(fieldName)); - } - docs.add(docMap); - }); - return docs; - } + /** + * Converts a SolrDocumentList to a List of Maps for optimized JSON + * serialization. + * + *

+ * This method transforms Solr's native document format into a structure that + * can be easily serialized to JSON and consumed by MCP clients. Each document + * becomes a flat map of field names to field values, preserving all data types. + * + *

+ * Conversion Process: + * + *

    + *
  • Iterates through each SolrDocument in the list + *
  • Extracts all field names and their corresponding values + *
  • Creates a HashMap for each document with field-value pairs + *
  • Preserves original data types (strings, numbers, dates, arrays) + *
+ * + *

+ * Performance Optimization: + * + *

+ * Pre-allocates the ArrayList with the known document count to minimize memory + * allocations and improve conversion performance for large result sets. + * + * @param documents + * the SolrDocumentList to convert from Solr's native format + * @return a List of Maps where each Map represents a document with field names + * as keys + * @see SolrDocument + * @see SolrDocumentList + */ + private static List> getDocs(SolrDocumentList documents) { + List> docs = new ArrayList<>(documents.size()); + documents.forEach(doc -> { + Map docMap = new HashMap<>(); + for (String fieldName : doc.getFieldNames()) { + docMap.put(fieldName, doc.getFieldValue(fieldName)); + } + docs.add(docMap); + }); + return docs; + } - /** - * Extracts facet information from a QueryResponse. - * - * @param queryResponse The QueryResponse containing facet results - * @return A Map where keys are facet field names and values are Maps of facet values to counts - */ - private static Map> getFacets(QueryResponse queryResponse) { - Map> facets = new HashMap<>(); - if (queryResponse.getFacetFields() != null && !queryResponse.getFacetFields().isEmpty()) { - queryResponse - .getFacetFields() - .forEach( - facetField -> { - Map facetValues = new HashMap<>(); - for (FacetField.Count count : facetField.getValues()) { - facetValues.put(count.getName(), count.getCount()); - } - facets.put(facetField.getName(), facetValues); - }); - } - return facets; - } + /** + * Extracts facet information from a QueryResponse. + * + * @param queryResponse + * The QueryResponse containing facet results + * @return A Map where keys are facet field names and values are Maps of facet + * values to counts + */ + private static Map> getFacets(QueryResponse queryResponse) { + Map> facets = new HashMap<>(); + if (queryResponse.getFacetFields() != null && !queryResponse.getFacetFields().isEmpty()) { + queryResponse.getFacetFields().forEach(facetField -> { + Map facetValues = new HashMap<>(); + for (FacetField.Count count : facetField.getValues()) { + facetValues.put(count.getName(), count.getCount()); + } + facets.put(facetField.getName(), facetValues); + }); + } + return facets; + } - /** - * Searches a Solr collection with the specified parameters. This method is exposed as a tool - * for MCP clients to use. - * - * @param collection The Solr collection to query - * @param query The Solr query string (q parameter). Defaults to "*:*" if not specified - * @param filterQueries List of filter queries (fq parameter) - * @param facetFields List of fields to facet on - * @param sortClauses List of sort clauses for ordering results - * @param start Starting offset for pagination - * @param rows Number of rows to return - * @return A SearchResponse containing the search results and facets - * @throws SolrServerException If there's an error communicating with Solr - * @throws IOException If there's an I/O error - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "Search", - description = - """ -Search specified Solr collection with query, optional filters, facets, sorting, and pagination. -Note that solr has dynamic fields where name of field in schema may end with suffixes -_s: Represents a string field, used for exact string matching. -_i: Represents an integer field. -_l: Represents a long field. -_f: Represents a float field. -_d: Represents a double field. -_dt: Represents a date field. -_b: Represents a boolean field. -_t: Often used for text fields that undergo tokenization and analysis. -One example from the books collection: -{ - "id":"0553579908", - "cat":["book"], - "name":["A Clash of Kings"], - "price":[7.99], - "inStock":[true], - "author":["George R.R. Martin"], - "series_t":"A Song of Ice and Fire", - "sequence_i":2, - "genre_s":"fantasy", - "_version_":1836275819373133824, - "_root_":"0553579908" - } -""") - public SearchResponse search( - @McpToolParam(description = "Solr collection to query") String collection, - @McpToolParam( - description = "Solr q parameter. If none specified defaults to \"*:*\"", - required = false) - String query, - @McpToolParam(description = "Solr fq parameter", required = false) - List filterQueries, - @McpToolParam(description = "Solr facet fields", required = false) - List facetFields, - @McpToolParam(description = "Solr sort parameter", required = false) - List> sortClauses, - @McpToolParam(description = "Starting offset for pagination", required = false) - Integer start, - @McpToolParam(description = "Number of rows to return", required = false) Integer rows) - throws SolrServerException, IOException { + /** + * Searches a Solr collection with the specified parameters. This method is + * exposed as a tool for MCP clients to use. + * + * @param collection + * The Solr collection to query + * @param query + * The Solr query string (q parameter). Defaults to "*:*" if not + * specified + * @param filterQueries + * List of filter queries (fq parameter) + * @param facetFields + * List of fields to facet on + * @param sortClauses + * List of sort clauses for ordering results + * @param start + * Starting offset for pagination + * @param rows + * Number of rows to return + * @return A SearchResponse containing the search results and facets + * @throws SolrServerException + * If there's an error communicating with Solr + * @throws IOException + * If there's an I/O error + */ + @PreAuthorize("isAuthenticated()") + @McpTool(name = "Search", description = """ + Search specified Solr collection with query, optional filters, facets, sorting, and pagination. + Note that solr has dynamic fields where name of field in schema may end with suffixes + _s: Represents a string field, used for exact string matching. + _i: Represents an integer field. + _l: Represents a long field. + _f: Represents a float field. + _d: Represents a double field. + _dt: Represents a date field. + _b: Represents a boolean field. + _t: Often used for text fields that undergo tokenization and analysis. + One example from the books collection: + { + "id":"0553579908", + "cat":["book"], + "name":["A Clash of Kings"], + "price":[7.99], + "inStock":[true], + "author":["George R.R. Martin"], + "series_t":"A Song of Ice and Fire", + "sequence_i":2, + "genre_s":"fantasy", + "_version_":1836275819373133824, + "_root_":"0553579908" + } + """) + public SearchResponse search(@McpToolParam(description = "Solr collection to query") String collection, + @McpToolParam(description = "Solr q parameter. If none specified defaults to \"*:*\"", required = false) String query, + @McpToolParam(description = "Solr fq parameter", required = false) List filterQueries, + @McpToolParam(description = "Solr facet fields", required = false) List facetFields, + @McpToolParam(description = "Solr sort parameter", required = false) List> sortClauses, + @McpToolParam(description = "Starting offset for pagination", required = false) Integer start, + @McpToolParam(description = "Number of rows to return", required = false) Integer rows) + throws SolrServerException, IOException { - // query - final SolrQuery solrQuery = new SolrQuery("*:*"); - if (StringUtils.hasText(query)) { - solrQuery.setQuery(query); - } + // query + final SolrQuery solrQuery = new SolrQuery("*:*"); + if (StringUtils.hasText(query)) { + solrQuery.setQuery(query); + } - // filter queries - if (!CollectionUtils.isEmpty(filterQueries)) { - solrQuery.setFilterQueries(filterQueries.toArray(new String[0])); - } + // filter queries + if (!CollectionUtils.isEmpty(filterQueries)) { + solrQuery.setFilterQueries(filterQueries.toArray(new String[0])); + } - // facets - if (!CollectionUtils.isEmpty(facetFields)) { - solrQuery.setFacet(true); - solrQuery.addFacetField(facetFields.toArray(new String[0])); - solrQuery.setFacetMinCount(1); - solrQuery.setFacetSort(FacetParams.FACET_SORT_COUNT); - } + // facets + if (!CollectionUtils.isEmpty(facetFields)) { + solrQuery.setFacet(true); + solrQuery.addFacetField(facetFields.toArray(new String[0])); + solrQuery.setFacetMinCount(1); + solrQuery.setFacetSort(FacetParams.FACET_SORT_COUNT); + } - // sorting - if (!CollectionUtils.isEmpty(sortClauses)) { - solrQuery.setSorts( - sortClauses.stream() - .map( - sortClause -> - new SolrQuery.SortClause( - sortClause.get(SORT_ITEM), - sortClause.get(SORT_ORDER))) - .toList()); - } + // sorting + if (!CollectionUtils.isEmpty(sortClauses)) { + solrQuery.setSorts(sortClauses.stream() + .map(sortClause -> new SolrQuery.SortClause(sortClause.get(SORT_ITEM), sortClause.get(SORT_ORDER))) + .toList()); + } - // pagination - if (start != null) { - solrQuery.setStart(start); - } + // pagination + if (start != null) { + solrQuery.setStart(start); + } - if (rows != null) { - solrQuery.setRows(rows); - } + if (rows != null) { + solrQuery.setRows(rows); + } - final QueryResponse queryResponse = solrClient.query(collection, solrQuery); + final QueryResponse queryResponse = solrClient.query(collection, solrQuery); - // Add documents - final SolrDocumentList documents = queryResponse.getResults(); + // Add documents + final SolrDocumentList documents = queryResponse.getResults(); - // Convert SolrDocuments to Maps - final var docs = getDocs(documents); + // Convert SolrDocuments to Maps + final var docs = getDocs(documents); - // Add facets if present - final var facets = getFacets(queryResponse); + // Add facets if present + final var facets = getFacets(queryResponse); - return new SearchResponse( - documents.getNumFound(), - documents.getStart(), - documents.getMaxScore(), - docs, - facets); - } + return new SearchResponse(documents.getNumFound(), documents.getStart(), documents.getMaxScore(), docs, facets); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/BuildInfoReader.java b/src/test/java/org/apache/solr/mcp/server/BuildInfoReader.java index 1e9b691..9db54a0 100644 --- a/src/test/java/org/apache/solr/mcp/server/BuildInfoReader.java +++ b/src/test/java/org/apache/solr/mcp/server/BuildInfoReader.java @@ -21,14 +21,17 @@ import java.util.Properties; /** - * Test utility class for reading build information from {@code META-INF/build-info.properties}. + * Test utility class for reading build information from + * {@code META-INF/build-info.properties}. * - *

This utility provides access to build artifact name and version that are generated by Spring - * Boot during the build process. It's primarily used by Docker integration tests to construct - * Docker image names dynamically. + *

+ * This utility provides access to build artifact name and version that are + * generated by Spring Boot during the build process. It's primarily used by + * Docker integration tests to construct Docker image names dynamically. * - *

Prerequisites: The build-info.properties file must be present in the test - * classpath. This is typically generated by running: + *

+ * Prerequisites: The build-info.properties file must be + * present in the test classpath. This is typically generated by running: * *

{@code
  * ./gradlew build
@@ -36,70 +39,68 @@
  */
 public class BuildInfoReader {
 
-    private static final String BUILD_INFO_PROPERTIES_PATH = "/META-INF/build-info.properties";
-    private static final Properties buildInfo = loadBuildInfo();
+	private static final String BUILD_INFO_PROPERTIES_PATH = "/META-INF/build-info.properties";
+	private static final Properties buildInfo = loadBuildInfo();
 
-    // Private constructor to prevent instantiation
-    private BuildInfoReader() {
-        throw new UnsupportedOperationException("Utility class");
-    }
+	// Private constructor to prevent instantiation
+	private BuildInfoReader() {
+		throw new UnsupportedOperationException("Utility class");
+	}
 
-    /**
-     * Loads build information from the properties file.
-     *
-     * @return Properties object containing build information
-     * @throws IllegalStateException if the properties file cannot be found or loaded
-     */
-    private static Properties loadBuildInfo() {
-        Properties properties = new Properties();
-        try (InputStream input =
-                BuildInfoReader.class.getResourceAsStream(BUILD_INFO_PROPERTIES_PATH)) {
-            if (input == null) {
-                throw new IllegalStateException(
-                        "build-info.properties not found at "
-                                + BUILD_INFO_PROPERTIES_PATH
-                                + ". Run './gradlew build' first.");
-            }
-            properties.load(input);
-        } catch (IOException e) {
-            throw new IllegalStateException("Failed to load build-info.properties", e);
-        }
-        return properties;
-    }
+	/**
+	 * Loads build information from the properties file.
+	 *
+	 * @return Properties object containing build information
+	 * @throws IllegalStateException
+	 *             if the properties file cannot be found or loaded
+	 */
+	private static Properties loadBuildInfo() {
+		Properties properties = new Properties();
+		try (InputStream input = BuildInfoReader.class.getResourceAsStream(BUILD_INFO_PROPERTIES_PATH)) {
+			if (input == null) {
+				throw new IllegalStateException("build-info.properties not found at " + BUILD_INFO_PROPERTIES_PATH
+						+ ". Run './gradlew build' first.");
+			}
+			properties.load(input);
+		} catch (IOException e) {
+			throw new IllegalStateException("Failed to load build-info.properties", e);
+		}
+		return properties;
+	}
 
-    /**
-     * Gets the build artifact name from build-info.properties.
-     *
-     * @return the artifact name (e.g., "solr-mcp")
-     */
-    public static String getArtifact() {
-        return buildInfo.getProperty("build.artifact");
-    }
+	/**
+	 * Gets the build artifact name from build-info.properties.
+	 *
+	 * @return the artifact name (e.g., "solr-mcp")
+	 */
+	public static String getArtifact() {
+		return buildInfo.getProperty("build.artifact");
+	}
 
-    /**
-     * Gets the build version from build-info.properties.
-     *
-     * @return the version string (e.g., "0.0.1-SNAPSHOT")
-     */
-    public static String getVersion() {
-        return buildInfo.getProperty("build.version");
-    }
+	/**
+	 * Gets the build version from build-info.properties.
+	 *
+	 * @return the version string (e.g., "0.0.1-SNAPSHOT")
+	 */
+	public static String getVersion() {
+		return buildInfo.getProperty("build.version");
+	}
 
-    /**
-     * Gets the Docker image name in the format "artifact:version".
-     *
-     * @return Docker image name (e.g., "solr-mcp:0.0.1-SNAPSHOT")
-     */
-    public static String getDockerImageName() {
-        return String.format("%s:%s", getArtifact(), getVersion());
-    }
+	/**
+	 * Gets the Docker image name in the format "artifact:version".
+	 *
+	 * @return Docker image name (e.g., "solr-mcp:0.0.1-SNAPSHOT")
+	 */
+	public static String getDockerImageName() {
+		return String.format("%s:%s", getArtifact(), getVersion());
+	}
 
-    /**
-     * Gets the JAR file name in the format "artifact-version.jar".
-     *
-     * @return JAR file name (e.g., "solr-mcp-0.0.1-SNAPSHOT.jar")
-     */
-    public static String getJarFileName() {
-        return String.format("%s-%s.jar", getArtifact(), getVersion());
-    }
+	/**
+	 * Gets the JAR file name in the format "artifact-version.jar".
+	 *
+	 * @return JAR file name (e.g., "solr-mcp-0.0.1-SNAPSHOT.jar")
+	 */
+	public static String getJarFileName() {
+		return String.format("%s-%s.jar", getArtifact(), getVersion());
+	}
 }
diff --git a/src/test/java/org/apache/solr/mcp/server/ClientHttp.java b/src/test/java/org/apache/solr/mcp/server/ClientHttp.java
index c0f434a..ebc03e5 100644
--- a/src/test/java/org/apache/solr/mcp/server/ClientHttp.java
+++ b/src/test/java/org/apache/solr/mcp/server/ClientHttp.java
@@ -22,8 +22,8 @@
 @Disabled("Enable only when MCP server is running in http mode")
 public class ClientHttp {
 
-    public static void main(String[] args) {
-        var transport = HttpClientStreamableHttpTransport.builder("http://localhost:8080").build();
-        new SampleClient(transport).run();
-    }
+	public static void main(String[] args) {
+		var transport = HttpClientStreamableHttpTransport.builder("http://localhost:8080").build();
+		new SampleClient(transport).run();
+	}
 }
diff --git a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java
index 1af93d4..ae6a231 100644
--- a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java
+++ b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java
@@ -25,15 +25,14 @@
 // connected to a running solr"
 public class ClientStdio {
 
-    static void main() {
+	static void main() {
 
-        String jarName = String.format("build/libs/%s", BuildInfoReader.getJarFileName());
+		String jarName = String.format("build/libs/%s", BuildInfoReader.getJarFileName());
 
-        var stdioParams = ServerParameters.builder("java").args("-jar", jarName).build();
+		var stdioParams = ServerParameters.builder("java").args("-jar", jarName).build();
 
-        var transport =
-                new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(new ObjectMapper()));
+		var transport = new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(new ObjectMapper()));
 
-        new SampleClient(transport).run();
-    }
+		new SampleClient(transport).run();
+	}
 }
diff --git a/src/test/java/org/apache/solr/mcp/server/MainTest.java b/src/test/java/org/apache/solr/mcp/server/MainTest.java
index 4b5765b..db6fdb7 100644
--- a/src/test/java/org/apache/solr/mcp/server/MainTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/MainTest.java
@@ -26,24 +26,29 @@
 import org.springframework.test.context.bean.override.mockito.MockitoBean;
 
 /**
- * Application context loading test with mocked services. This test verifies that the Spring
- * application context can be loaded successfully without requiring actual Solr connections, using
- * mocked beans to prevent external dependencies.
+ * Application context loading test with mocked services. This test verifies
+ * that the Spring application context can be loaded successfully without
+ * requiring actual Solr connections, using mocked beans to prevent external
+ * dependencies.
  */
 @SpringBootTest
 @ActiveProfiles("test")
 class MainTest {
 
-    @MockitoBean private SearchService searchService;
+	@MockitoBean
+	private SearchService searchService;
 
-    @MockitoBean private IndexingService indexingService;
+	@MockitoBean
+	private IndexingService indexingService;
 
-    @MockitoBean private CollectionService collectionService;
+	@MockitoBean
+	private CollectionService collectionService;
 
-    @MockitoBean private SchemaService schemaService;
+	@MockitoBean
+	private SchemaService schemaService;
 
-    @Test
-    void contextLoads() {
-        // Context loading test - all services are mocked to prevent Solr API calls
-    }
+	@Test
+	void contextLoads() {
+		// Context loading test - all services are mocked to prevent Solr API calls
+	}
 }
diff --git a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java
index 15da485..cff7701 100644
--- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java
@@ -31,212 +31,163 @@
 import org.springaicommunity.mcp.annotation.McpToolParam;
 
 /**
- * Tests for MCP tool registration and annotation validation. Ensures all services expose their
- * methods correctly as MCP tools with proper annotations and descriptions.
+ * Tests for MCP tool registration and annotation validation. Ensures all
+ * services expose their methods correctly as MCP tools with proper annotations
+ * and descriptions.
  */
 class McpToolRegistrationTest {
 
-    @Test
-    void testSearchServiceHasToolAnnotation() throws NoSuchMethodException {
-        // Get the search method from SearchService
-        Method searchMethod =
-                SearchService.class.getMethod(
-                        "search",
-                        String.class,
-                        String.class,
-                        List.class,
-                        List.class,
-                        List.class,
-                        Integer.class,
-                        Integer.class);
-
-        // Verify it has the @McpTool annotation
-        assertTrue(
-                searchMethod.isAnnotationPresent(McpTool.class),
-                "SearchService.search method should have @McpTool annotation");
-
-        // Verify the annotation properties
-        McpTool toolAnnotation = searchMethod.getAnnotation(McpTool.class);
-        assertEquals("Search", toolAnnotation.name(), "McpTool name should be 'Search'");
-        assertNotNull(toolAnnotation.description(), "McpTool description should not be null");
-        assertFalse(
-                toolAnnotation.description().isBlank(), "McpTool description should not be blank");
-    }
-
-    @Test
-    void testSearchServiceToolParametersHaveAnnotations() throws NoSuchMethodException {
-        // Get the search method
-        Method searchMethod =
-                SearchService.class.getMethod(
-                        "search",
-                        String.class,
-                        String.class,
-                        List.class,
-                        List.class,
-                        List.class,
-                        Integer.class,
-                        Integer.class);
-
-        // Verify all parameters have @McpToolParam annotations
-        Parameter[] parameters = searchMethod.getParameters();
-        assertTrue(parameters.length > 0, "Search method should have parameters");
-
-        for (Parameter param : parameters) {
-            assertTrue(
-                    param.isAnnotationPresent(McpToolParam.class),
-                    "Parameter " + param.getName() + " should have @McpToolParam annotation");
-
-            McpToolParam paramAnnotation = param.getAnnotation(McpToolParam.class);
-            assertNotNull(
-                    paramAnnotation.description(),
-                    "Parameter " + param.getName() + " should have description");
-            assertFalse(
-                    paramAnnotation.description().isBlank(),
-                    "Parameter " + param.getName() + " description should not be blank");
-        }
-    }
-
-    @Test
-    void testIndexingServiceHasToolAnnotations() {
-        // Get all methods from IndexingService
-        Method[] methods = IndexingService.class.getDeclaredMethods();
-
-        // Find methods with @McpTool annotation
-        List mcpToolMethods =
-                Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList();
-
-        // Verify at least one method has the annotation
-        assertFalse(
-                mcpToolMethods.isEmpty(),
-                "IndexingService should have at least one method with @McpTool annotation");
-
-        // Verify each tool has proper annotations
-        for (Method method : mcpToolMethods) {
-            McpTool toolAnnotation = method.getAnnotation(McpTool.class);
-            assertNotNull(toolAnnotation.name(), "Tool name should not be null");
-            assertFalse(toolAnnotation.name().isBlank(), "Tool name should not be blank");
-            assertNotNull(toolAnnotation.description(), "Tool description should not be null");
-            assertFalse(
-                    toolAnnotation.description().isBlank(), "Tool description should not be blank");
-        }
-    }
-
-    @Test
-    void testCollectionServiceHasToolAnnotations() {
-        // Get all methods from CollectionService
-        Method[] methods = CollectionService.class.getDeclaredMethods();
-
-        // Find methods with @McpTool annotation
-        List mcpToolMethods =
-                Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList();
-
-        // Verify at least one method has the annotation
-        assertFalse(
-                mcpToolMethods.isEmpty(),
-                "CollectionService should have at least one method with @McpTool annotation");
-
-        // Verify each tool has proper annotations
-        for (Method method : mcpToolMethods) {
-            McpTool toolAnnotation = method.getAnnotation(McpTool.class);
-            assertNotNull(toolAnnotation.description(), "Tool description should not be null");
-            assertFalse(
-                    toolAnnotation.description().isBlank(), "Tool description should not be blank");
-        }
-    }
-
-    @Test
-    void testSchemaServiceHasToolAnnotations() {
-        // Get all methods from SchemaService
-        Method[] methods = SchemaService.class.getDeclaredMethods();
-
-        // Find methods with @McpTool annotation
-        List mcpToolMethods =
-                Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList();
-
-        // Verify at least one method has the annotation
-        assertFalse(
-                mcpToolMethods.isEmpty(),
-                "SchemaService should have at least one method with @McpTool annotation");
-
-        // Verify each tool has proper annotations
-        for (Method method : mcpToolMethods) {
-            McpTool toolAnnotation = method.getAnnotation(McpTool.class);
-            assertNotNull(toolAnnotation.description(), "Tool description should not be null");
-            assertFalse(
-                    toolAnnotation.description().isBlank(), "Tool description should not be blank");
-        }
-    }
-
-    @Test
-    void testAllMcpToolsHaveUniqueNames() {
-        // Collect all MCP tool names from all services
-        List toolNames = new java.util.ArrayList<>();
-
-        // SearchService
-        addToolNames(SearchService.class, toolNames);
-
-        // IndexingService
-        addToolNames(IndexingService.class, toolNames);
-
-        // CollectionService
-        addToolNames(CollectionService.class, toolNames);
-
-        // SchemaService
-        addToolNames(SchemaService.class, toolNames);
-
-        // Verify all tool names are unique
-        long uniqueCount = toolNames.stream().distinct().count();
-        assertEquals(
-                toolNames.size(),
-                uniqueCount,
-                "All MCP tool names should be unique across all services. Found tools: "
-                        + toolNames);
-    }
-
-    @Test
-    void testMcpToolParametersFollowConventions() throws NoSuchMethodException {
-        // Get the search method
-        Method searchMethod =
-                SearchService.class.getMethod(
-                        "search",
-                        String.class,
-                        String.class,
-                        List.class,
-                        List.class,
-                        List.class,
-                        Integer.class,
-                        Integer.class);
-
-        Parameter[] parameters = searchMethod.getParameters();
-
-        // Verify first parameter (collection) is required
-        McpToolParam firstParam = parameters[0].getAnnotation(McpToolParam.class);
-        assertTrue(
-                firstParam.required() || !firstParam.required(),
-                "First parameter annotation should specify required status");
-
-        // Verify optional parameters have required=false
-        for (int i = 1; i < parameters.length; i++) {
-            McpToolParam param = parameters[i].getAnnotation(McpToolParam.class);
-            // Optional parameters should be marked as such in description or required flag
-            assertNotNull(
-                    param.description(),
-                    "Parameter should have description indicating if it's optional");
-        }
-    }
-
-    // Helper method to extract tool names from a service class
-    private void addToolNames(Class serviceClass, List toolNames) {
-        Method[] methods = serviceClass.getDeclaredMethods();
-        Arrays.stream(methods)
-                .filter(m -> m.isAnnotationPresent(McpTool.class))
-                .forEach(
-                        m -> {
-                            McpTool annotation = m.getAnnotation(McpTool.class);
-                            // Use name if provided, otherwise use method name
-                            String toolName =
-                                    annotation.name().isBlank() ? m.getName() : annotation.name();
-                            toolNames.add(toolName);
-                        });
-    }
+	@Test
+	void testSearchServiceHasToolAnnotation() throws NoSuchMethodException {
+		// Get the search method from SearchService
+		Method searchMethod = SearchService.class.getMethod("search", String.class, String.class, List.class,
+				List.class, List.class, Integer.class, Integer.class);
+
+		// Verify it has the @McpTool annotation
+		assertTrue(searchMethod.isAnnotationPresent(McpTool.class),
+				"SearchService.search method should have @McpTool annotation");
+
+		// Verify the annotation properties
+		McpTool toolAnnotation = searchMethod.getAnnotation(McpTool.class);
+		assertEquals("Search", toolAnnotation.name(), "McpTool name should be 'Search'");
+		assertNotNull(toolAnnotation.description(), "McpTool description should not be null");
+		assertFalse(toolAnnotation.description().isBlank(), "McpTool description should not be blank");
+	}
+
+	@Test
+	void testSearchServiceToolParametersHaveAnnotations() throws NoSuchMethodException {
+		// Get the search method
+		Method searchMethod = SearchService.class.getMethod("search", String.class, String.class, List.class,
+				List.class, List.class, Integer.class, Integer.class);
+
+		// Verify all parameters have @McpToolParam annotations
+		Parameter[] parameters = searchMethod.getParameters();
+		assertTrue(parameters.length > 0, "Search method should have parameters");
+
+		for (Parameter param : parameters) {
+			assertTrue(param.isAnnotationPresent(McpToolParam.class),
+					"Parameter " + param.getName() + " should have @McpToolParam annotation");
+
+			McpToolParam paramAnnotation = param.getAnnotation(McpToolParam.class);
+			assertNotNull(paramAnnotation.description(), "Parameter " + param.getName() + " should have description");
+			assertFalse(paramAnnotation.description().isBlank(),
+					"Parameter " + param.getName() + " description should not be blank");
+		}
+	}
+
+	@Test
+	void testIndexingServiceHasToolAnnotations() {
+		// Get all methods from IndexingService
+		Method[] methods = IndexingService.class.getDeclaredMethods();
+
+		// Find methods with @McpTool annotation
+		List mcpToolMethods = Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList();
+
+		// Verify at least one method has the annotation
+		assertFalse(mcpToolMethods.isEmpty(),
+				"IndexingService should have at least one method with @McpTool annotation");
+
+		// Verify each tool has proper annotations
+		for (Method method : mcpToolMethods) {
+			McpTool toolAnnotation = method.getAnnotation(McpTool.class);
+			assertNotNull(toolAnnotation.name(), "Tool name should not be null");
+			assertFalse(toolAnnotation.name().isBlank(), "Tool name should not be blank");
+			assertNotNull(toolAnnotation.description(), "Tool description should not be null");
+			assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank");
+		}
+	}
+
+	@Test
+	void testCollectionServiceHasToolAnnotations() {
+		// Get all methods from CollectionService
+		Method[] methods = CollectionService.class.getDeclaredMethods();
+
+		// Find methods with @McpTool annotation
+		List mcpToolMethods = Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList();
+
+		// Verify at least one method has the annotation
+		assertFalse(mcpToolMethods.isEmpty(),
+				"CollectionService should have at least one method with @McpTool annotation");
+
+		// Verify each tool has proper annotations
+		for (Method method : mcpToolMethods) {
+			McpTool toolAnnotation = method.getAnnotation(McpTool.class);
+			assertNotNull(toolAnnotation.description(), "Tool description should not be null");
+			assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank");
+		}
+	}
+
+	@Test
+	void testSchemaServiceHasToolAnnotations() {
+		// Get all methods from SchemaService
+		Method[] methods = SchemaService.class.getDeclaredMethods();
+
+		// Find methods with @McpTool annotation
+		List mcpToolMethods = Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList();
+
+		// Verify at least one method has the annotation
+		assertFalse(mcpToolMethods.isEmpty(), "SchemaService should have at least one method with @McpTool annotation");
+
+		// Verify each tool has proper annotations
+		for (Method method : mcpToolMethods) {
+			McpTool toolAnnotation = method.getAnnotation(McpTool.class);
+			assertNotNull(toolAnnotation.description(), "Tool description should not be null");
+			assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank");
+		}
+	}
+
+	@Test
+	void testAllMcpToolsHaveUniqueNames() {
+		// Collect all MCP tool names from all services
+		List toolNames = new java.util.ArrayList<>();
+
+		// SearchService
+		addToolNames(SearchService.class, toolNames);
+
+		// IndexingService
+		addToolNames(IndexingService.class, toolNames);
+
+		// CollectionService
+		addToolNames(CollectionService.class, toolNames);
+
+		// SchemaService
+		addToolNames(SchemaService.class, toolNames);
+
+		// Verify all tool names are unique
+		long uniqueCount = toolNames.stream().distinct().count();
+		assertEquals(toolNames.size(), uniqueCount,
+				"All MCP tool names should be unique across all services. Found tools: " + toolNames);
+	}
+
+	@Test
+	void testMcpToolParametersFollowConventions() throws NoSuchMethodException {
+		// Get the search method
+		Method searchMethod = SearchService.class.getMethod("search", String.class, String.class, List.class,
+				List.class, List.class, Integer.class, Integer.class);
+
+		Parameter[] parameters = searchMethod.getParameters();
+
+		// Verify first parameter (collection) is required
+		McpToolParam firstParam = parameters[0].getAnnotation(McpToolParam.class);
+		assertTrue(firstParam.required() || !firstParam.required(),
+				"First parameter annotation should specify required status");
+
+		// Verify optional parameters have required=false
+		for (int i = 1; i < parameters.length; i++) {
+			McpToolParam param = parameters[i].getAnnotation(McpToolParam.class);
+			// Optional parameters should be marked as such in description or required flag
+			assertNotNull(param.description(), "Parameter should have description indicating if it's optional");
+		}
+	}
+
+	// Helper method to extract tool names from a service class
+	private void addToolNames(Class serviceClass, List toolNames) {
+		Method[] methods = serviceClass.getDeclaredMethods();
+		Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).forEach(m -> {
+			McpTool annotation = m.getAnnotation(McpTool.class);
+			// Use name if provided, otherwise use method name
+			String toolName = annotation.name().isBlank() ? m.getName() : annotation.name();
+			toolNames.add(toolName);
+		});
+	}
 }
diff --git a/src/test/java/org/apache/solr/mcp/server/SampleClient.java b/src/test/java/org/apache/solr/mcp/server/SampleClient.java
index 6ceae3b..6d9fa89 100644
--- a/src/test/java/org/apache/solr/mcp/server/SampleClient.java
+++ b/src/test/java/org/apache/solr/mcp/server/SampleClient.java
@@ -26,36 +26,49 @@
 import java.util.Set;
 
 /**
- * Sample MCP client for testing and demonstrating Solr MCP Server functionality.
+ * Sample MCP client for testing and demonstrating Solr MCP Server
+ * functionality.
  *
- * 

This test client provides a comprehensive validation suite for the Solr MCP Server, verifying - * that all expected MCP tools are properly registered and functioning as expected. It serves as - * both a testing framework and a reference implementation for MCP client integration. + *

+ * This test client provides a comprehensive validation suite for the Solr MCP + * Server, verifying that all expected MCP tools are properly registered and + * functioning as expected. It serves as both a testing framework and a + * reference implementation for MCP client integration. * - *

Test Coverage: + *

+ * Test Coverage: * *

    - *
  • Client Initialization: Verifies MCP client can connect and initialize - *
  • Connection Health: Tests ping functionality and connection stability - *
  • Tool Discovery: Validates all expected MCP tools are registered - *
  • Tool Validation: Checks tool metadata, descriptions, and schemas - *
  • Expected Tools: Verifies presence of search, indexing, and metadata tools + *
  • Client Initialization: Verifies MCP client can connect + * and initialize + *
  • Connection Health: Tests ping functionality and + * connection stability + *
  • Tool Discovery: Validates all expected MCP tools are + * registered + *
  • Tool Validation: Checks tool metadata, descriptions, and + * schemas + *
  • Expected Tools: Verifies presence of search, indexing, + * and metadata tools *
* - *

Expected MCP Tools: + *

+ * Expected MCP Tools: * *

    - *
  • index_json_documents: JSON document indexing capability - *
  • index_csv_documents: CSV document indexing capability - *
  • index_xml_documents: XML document indexing capability - *
  • Search: Full-text search functionality with filtering and faceting - *
  • listCollections: Collection discovery and listing - *
  • getCollectionStats: Collection metrics and performance data - *
  • checkHealth: Health monitoring and status reporting - *
  • getSchema: Schema introspection and field analysis + *
  • index_json_documents: JSON document indexing capability + *
  • index_csv_documents: CSV document indexing capability + *
  • index_xml_documents: XML document indexing capability + *
  • Search: Full-text search functionality with filtering + * and faceting + *
  • listCollections: Collection discovery and listing + *
  • getCollectionStats: Collection metrics and performance + * data + *
  • checkHealth: Health monitoring and status reporting + *
  • getSchema: Schema introspection and field analysis *
* - *

Usage Example: + *

+ * Usage Example: * *

{@code
  * McpClientTransport transport = // ... initialize transport
@@ -63,11 +76,13 @@
  * client.run(); // Executes full test suite
  * }
* - *

Assertion Strategy: + *

+ * Assertion Strategy: * - *

Uses JUnit assertions to validate expected behavior and fail fast on any inconsistencies. Each - * tool is validated for proper name, description, and schema configuration to ensure MCP protocol - * compliance. + *

+ * Uses JUnit assertions to validate expected behavior and fail fast on any + * inconsistencies. Each tool is validated for proper name, description, and + * schema configuration to ensure MCP protocol compliance. * * @version 0.0.1 * @since 0.0.1 @@ -77,155 +92,124 @@ */ public class SampleClient { - private final McpClientTransport transport; - - /** - * Constructs a new SampleClient with the specified MCP transport. - * - * @param transport the MCP client transport for communication with the Solr MCP Server - * @throws IllegalArgumentException if transport is null - */ - public SampleClient(McpClientTransport transport) { - this.transport = transport; - } - - /** - * Executes the comprehensive test suite for Solr MCP Server functionality. - * - *

This method performs a complete validation of the MCP server including: - * - *

    - *
  • Client initialization and connection establishment - *
  • Health check via ping operation - *
  • Tool discovery and count validation - *
  • Individual tool metadata validation - *
  • Tool-specific description and schema verification - *
- * - *

Test Sequence: - * - *

    - *
  1. Initialize MCP client with provided transport - *
  2. Perform ping test to verify connectivity - *
  3. List all available tools and validate expected count (8 tools) - *
  4. Verify each expected tool is present in the tools list - *
  5. Validate tool metadata (name, description, schema) for each tool - *
  6. Perform tool-specific validation based on tool type - *
- * - * @throws RuntimeException if any test assertion fails or MCP operations encounter errors - * @throws AssertionError if expected tools are missing or tool validation fails - */ - public void run() { - - try (var client = - McpClient.sync(this.transport) - .loggingConsumer( - message -> System.out.println(">> Client Logging: " + message)) - .build()) { - - // Assert client initialization succeeds - assertDoesNotThrow( - client::initialize, "Client initialization should not throw an exception"); - - // Assert ping succeeds - assertDoesNotThrow(client::ping, "Client ping should not throw an exception"); - - // List and validate tools - ListToolsResult toolsList = client.listTools(); - assertNotNull(toolsList, "Tools list should not be null"); - assertNotNull(toolsList.tools(), "Tools collection should not be null"); - - // Validate expected tool count based on MCP server implementation - assertEquals(8, toolsList.tools().size(), "Expected 8 tools to be available"); - - // Define expected tools based on the log output - Set expectedToolNames = - Set.of( - "index_json_documents", - "index_csv_documents", - "getCollectionStats", - "Search", - "listCollections", - "checkHealth", - "index_xml_documents", - "getSchema"); - - // Validate each expected tool is present - List actualToolNames = toolsList.tools().stream().map(Tool::name).toList(); - - for (String expectedTool : expectedToolNames) { - assertTrue( - actualToolNames.contains(expectedTool), - "Expected tool '" + expectedTool + "' should be available"); - } - - // Validate tool details for key tools - toolsList - .tools() - .forEach( - tool -> { - assertNotNull(tool.name(), "Tool name should not be null"); - assertNotNull( - tool.description(), "Tool description should not be null"); - assertNotNull( - tool.inputSchema(), "Tool input schema should not be null"); - assertFalse( - tool.name().trim().isEmpty(), - "Tool name should not be empty"); - assertFalse( - tool.description().trim().isEmpty(), - "Tool description should not be empty"); - - // Validate specific tools based on expected behavior - switch (tool.name()) { - case "index_json_documents": - assertTrue( - tool.description().toLowerCase().contains("json"), - "JSON indexing tool should mention JSON in" - + " description"); - break; - case "index_csv_documents": - assertTrue( - tool.description().toLowerCase().contains("csv"), - "CSV indexing tool should mention CSV in" - + " description"); - break; - case "Search": - assertTrue( - tool.description().toLowerCase().contains("search"), - "Search tool should mention search in description"); - break; - case "listCollections": - assertTrue( - tool.description() - .toLowerCase() - .contains("collection"), - "List collections tool should mention collections" - + " in description"); - break; - case "checkHealth": - assertTrue( - tool.description().toLowerCase().contains("health"), - "Health check tool should mention health in" - + " description"); - break; - default: - // Additional tools are acceptable - break; - } - - System.out.println( - "Tool: " - + tool.name() - + ", description: " - + tool.description() - + ", schema: " - + tool.inputSchema()); - }); - - } catch (Exception e) { - throw new RuntimeException("MCP client operation failed", e); - } - } + private final McpClientTransport transport; + + /** + * Constructs a new SampleClient with the specified MCP transport. + * + * @param transport + * the MCP client transport for communication with the Solr MCP + * Server + * @throws IllegalArgumentException + * if transport is null + */ + public SampleClient(McpClientTransport transport) { + this.transport = transport; + } + + /** + * Executes the comprehensive test suite for Solr MCP Server functionality. + * + *

+ * This method performs a complete validation of the MCP server including: + * + *

    + *
  • Client initialization and connection establishment + *
  • Health check via ping operation + *
  • Tool discovery and count validation + *
  • Individual tool metadata validation + *
  • Tool-specific description and schema verification + *
+ * + *

+ * Test Sequence: + * + *

    + *
  1. Initialize MCP client with provided transport + *
  2. Perform ping test to verify connectivity + *
  3. List all available tools and validate expected count (8 tools) + *
  4. Verify each expected tool is present in the tools list + *
  5. Validate tool metadata (name, description, schema) for each tool + *
  6. Perform tool-specific validation based on tool type + *
+ * + * @throws RuntimeException + * if any test assertion fails or MCP operations encounter errors + * @throws AssertionError + * if expected tools are missing or tool validation fails + */ + public void run() { + + try (var client = McpClient.sync(this.transport) + .loggingConsumer(message -> System.out.println(">> Client Logging: " + message)).build()) { + + // Assert client initialization succeeds + assertDoesNotThrow(client::initialize, "Client initialization should not throw an exception"); + + // Assert ping succeeds + assertDoesNotThrow(client::ping, "Client ping should not throw an exception"); + + // List and validate tools + ListToolsResult toolsList = client.listTools(); + assertNotNull(toolsList, "Tools list should not be null"); + assertNotNull(toolsList.tools(), "Tools collection should not be null"); + + // Validate expected tool count based on MCP server implementation + assertEquals(8, toolsList.tools().size(), "Expected 8 tools to be available"); + + // Define expected tools based on the log output + Set expectedToolNames = Set.of("index_json_documents", "index_csv_documents", "getCollectionStats", + "Search", "listCollections", "checkHealth", "index_xml_documents", "getSchema"); + + // Validate each expected tool is present + List actualToolNames = toolsList.tools().stream().map(Tool::name).toList(); + + for (String expectedTool : expectedToolNames) { + assertTrue(actualToolNames.contains(expectedTool), + "Expected tool '" + expectedTool + "' should be available"); + } + + // Validate tool details for key tools + toolsList.tools().forEach(tool -> { + assertNotNull(tool.name(), "Tool name should not be null"); + assertNotNull(tool.description(), "Tool description should not be null"); + assertNotNull(tool.inputSchema(), "Tool input schema should not be null"); + assertFalse(tool.name().trim().isEmpty(), "Tool name should not be empty"); + assertFalse(tool.description().trim().isEmpty(), "Tool description should not be empty"); + + // Validate specific tools based on expected behavior + switch (tool.name()) { + case "index_json_documents" : + assertTrue(tool.description().toLowerCase().contains("json"), + "JSON indexing tool should mention JSON in" + " description"); + break; + case "index_csv_documents" : + assertTrue(tool.description().toLowerCase().contains("csv"), + "CSV indexing tool should mention CSV in" + " description"); + break; + case "Search" : + assertTrue(tool.description().toLowerCase().contains("search"), + "Search tool should mention search in description"); + break; + case "listCollections" : + assertTrue(tool.description().toLowerCase().contains("collection"), + "List collections tool should mention collections" + " in description"); + break; + case "checkHealth" : + assertTrue(tool.description().toLowerCase().contains("health"), + "Health check tool should mention health in" + " description"); + break; + default : + // Additional tools are acceptable + break; + } + + System.out.println("Tool: " + tool.name() + ", description: " + tool.description() + ", schema: " + + tool.inputSchema()); + }); + + } catch (Exception e) { + throw new RuntimeException("MCP client operation failed", e); + } + } } diff --git a/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java b/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java index ea73984..d841108 100644 --- a/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java +++ b/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java @@ -25,23 +25,16 @@ @TestConfiguration(proxyBeanMethods = false) public class TestcontainersConfiguration { - private static final int SOLR_PORT = 8983; + private static final int SOLR_PORT = 8983; - @Bean - SolrContainer solr() { - return new SolrContainer(DockerImageName.parse("solr:9.9-slim")); - } + @Bean + SolrContainer solr() { + return new SolrContainer(DockerImageName.parse("solr:9.9-slim")); + } - @Bean - DynamicPropertyRegistrar propertiesRegistrar(SolrContainer solr) { - return registry -> - registry.add( - "solr.url", - () -> - "http://" - + solr.getHost() - + ":" - + solr.getMappedPort(SOLR_PORT) - + "/solr/"); - } + @Bean + DynamicPropertyRegistrar propertiesRegistrar(SolrContainer solr) { + return registry -> registry.add("solr.url", + () -> "http://" + solr.getHost() + ":" + solr.getMappedPort(SOLR_PORT) + "/solr/"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index 12da2b8..9423368 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -33,151 +33,139 @@ @Import(TestcontainersConfiguration.class) class SolrConfigTest { - @Autowired private SolrClient solrClient; - - @Autowired SolrContainer solrContainer; - - @Autowired private SolrConfigurationProperties properties; - - @Test - void testSolrClientConfiguration() { - // Verify that the SolrClient is properly configured - assertNotNull(solrClient); - - // Verify that the SolrClient is using the correct URL - // Note: SolrConfig normalizes the URL to have trailing slash, but Http2SolrClient removes - // it - var httpSolrClient = assertInstanceOf(Http2SolrClient.class, solrClient); - String expectedUrl = - "http://" - + solrContainer.getHost() - + ":" - + solrContainer.getMappedPort(8983) - + "/solr"; - assertEquals(expectedUrl, httpSolrClient.getBaseURL()); - } - - @Test - void testSolrConfigurationProperties() { - // Verify that the properties are correctly loaded - assertNotNull(properties); - assertNotNull(properties.url()); - assertEquals( - "http://" - + solrContainer.getHost() - + ":" - + solrContainer.getMappedPort(8983) - + "/solr/", - properties.url()); - } - - @ParameterizedTest - @CsvSource({ - "http://localhost:8983, http://localhost:8983/solr", - "http://localhost:8983/, http://localhost:8983/solr", - "http://localhost:8983/solr, http://localhost:8983/solr", - "http://localhost:8983/solr/, http://localhost:8983/solr", - "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr" - }) - void testUrlNormalization(String inputUrl, String expectedUrl) { - // Create a test properties object - SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); - - // Create SolrConfig instance - SolrConfig solrConfig = new SolrConfig(); - - // Test URL normalization - SolrClient client = solrConfig.solrClient(testProperties); - assertNotNull(client); - - var httpClient = assertInstanceOf(Http2SolrClient.class, client); - assertEquals(expectedUrl, httpClient.getBaseURL()); - - // Clean up - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithoutTrailingSlash() { - // Test URL without trailing slash branch - SolrConfigurationProperties testProperties = - new SolrConfigurationProperties("http://localhost:8983"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add trailing slash and solr path - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithTrailingSlashButNoSolrPath() { - // Test URL with trailing slash but no solr path branch - SolrConfigurationProperties testProperties = - new SolrConfigurationProperties("http://localhost:8983/"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add solr path to existing trailing slash - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithSolrPathButNoTrailingSlash() { - // Test URL with solr path but no trailing slash - SolrConfigurationProperties testProperties = - new SolrConfigurationProperties("http://localhost:8983/solr"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add trailing slash - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlAlreadyProperlyFormatted() { - // Test URL that's already properly formatted - SolrConfigurationProperties testProperties = - new SolrConfigurationProperties("http://localhost:8983/solr/"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should remain unchanged - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } + @Autowired + private SolrClient solrClient; + + @Autowired + SolrContainer solrContainer; + + @Autowired + private SolrConfigurationProperties properties; + + @Test + void testSolrClientConfiguration() { + // Verify that the SolrClient is properly configured + assertNotNull(solrClient); + + // Verify that the SolrClient is using the correct URL + // Note: SolrConfig normalizes the URL to have trailing slash, but + // Http2SolrClient removes + // it + var httpSolrClient = assertInstanceOf(Http2SolrClient.class, solrClient); + String expectedUrl = "http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr"; + assertEquals(expectedUrl, httpSolrClient.getBaseURL()); + } + + @Test + void testSolrConfigurationProperties() { + // Verify that the properties are correctly loaded + assertNotNull(properties); + assertNotNull(properties.url()); + assertEquals("http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr/", + properties.url()); + } + + @ParameterizedTest + @CsvSource({"http://localhost:8983, http://localhost:8983/solr", + "http://localhost:8983/, http://localhost:8983/solr", + "http://localhost:8983/solr, http://localhost:8983/solr", + "http://localhost:8983/solr/, http://localhost:8983/solr", + "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr"}) + void testUrlNormalization(String inputUrl, String expectedUrl) { + // Create a test properties object + SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); + + // Create SolrConfig instance + SolrConfig solrConfig = new SolrConfig(); + + // Test URL normalization + SolrClient client = solrConfig.solrClient(testProperties); + assertNotNull(client); + + var httpClient = assertInstanceOf(Http2SolrClient.class, client); + assertEquals(expectedUrl, httpClient.getBaseURL()); + + // Clean up + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlWithoutTrailingSlash() { + // Test URL without trailing slash branch + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties); + Http2SolrClient httpClient = (Http2SolrClient) client; + + // Should add trailing slash and solr path + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlWithTrailingSlashButNoSolrPath() { + // Test URL with trailing slash but no solr path branch + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties); + Http2SolrClient httpClient = (Http2SolrClient) client; + + // Should add solr path to existing trailing slash + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlWithSolrPathButNoTrailingSlash() { + // Test URL with solr path but no trailing slash + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties); + Http2SolrClient httpClient = (Http2SolrClient) client; + + // Should add trailing slash + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlAlreadyProperlyFormatted() { + // Test URL that's already properly formatted + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties); + Http2SolrClient httpClient = (Http2SolrClient) client; + + // Should remain unchanged + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } } diff --git a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java index 5594e0d..e9b068f 100644 --- a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java @@ -45,204 +45,186 @@ import org.testcontainers.utility.DockerImageName; /** - * Integration test for the Docker image produced by Jib running in HTTP mode (streamable HTTP). + * Integration test for the Docker image produced by Jib running in HTTP mode + * (streamable HTTP). * - *

This test verifies that the Docker image built by Jib: + *

+ * This test verifies that the Docker image built by Jib: * *

    - *
  • Starts successfully without errors in HTTP mode - *
  • Runs the Spring Boot MCP server application correctly - *
  • Exposes HTTP endpoint on port 8080 - *
  • Responds to HTTP requests - *
  • Can connect to an external Solr instance + *
  • Starts successfully without errors in HTTP mode + *
  • Runs the Spring Boot MCP server application correctly + *
  • Exposes HTTP endpoint on port 8080 + *
  • Responds to HTTP requests + *
  • Can connect to an external Solr instance *
* - *

Prerequisites: Before running this test, you must build the Docker image: + *

+ * Prerequisites: Before running this test, you must build the + * Docker image: * *

{@code
  * ./gradlew jibDockerBuild
  * }
* - *

The image name and version are read from {@code META-INF/build-info.properties} + *

+ * The image name and version are read from + * {@code META-INF/build-info.properties} * - *

Test Architecture: + *

+ * Test Architecture: * *

    - *
  1. Creates a shared Docker network for inter-container communication - *
  2. Starts a Solr container on the network - *
  3. Starts the MCP server Docker image in HTTP mode with connection to Solr - *
  4. Verifies the container starts and HTTP endpoint is accessible - *
  5. Validates HTTP responses and container health + *
  6. Creates a shared Docker network for inter-container communication + *
  7. Starts a Solr container on the network + *
  8. Starts the MCP server Docker image in HTTP mode with connection to Solr + *
  9. Verifies the container starts and HTTP endpoint is accessible + *
  10. Validates HTTP responses and container health *
* - *

Note: This test is tagged with "docker-integration" and is designed to run - * separately from regular unit tests using the {@code dockerIntegrationTest} Gradle task. + *

+ * Note: This test is tagged with "docker-integration" and is + * designed to run separately from regular unit tests using the + * {@code dockerIntegrationTest} Gradle task. */ @Testcontainers @Tag("docker-integration") class DockerImageHttpIntegrationTest { - private static final Logger log = LoggerFactory.getLogger(DockerImageHttpIntegrationTest.class); - - // Docker image name and tag from build-info.properties - private static final String DOCKER_IMAGE = BuildInfoReader.getDockerImageName(); - private static final String SOLR_IMAGE = "solr:9.9-slim"; - private static final int HTTP_PORT = 8080; - - // Network for container communication - private static final Network network = Network.newNetwork(); - - // Solr container for backend - // Note: This field is used implicitly through the @Container annotation. - // Testcontainers JUnit extension automatically: - // 1. Starts this container before tests run - // 2. Makes it accessible via network alias "solr" at http://solr:8983/solr/ - // 3. Stops and cleans up the container after tests complete - @Container - private static final SolrContainer solrContainer = - new SolrContainer(DockerImageName.parse(SOLR_IMAGE)) - .withNetwork(network) - .withNetworkAliases("solr") - .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("SOLR")); - - // MCP Server container (the image we're testing) - // Note: In HTTP mode, the application exposes a web server on port 8080 - @Container - private static final GenericContainer mcpServerContainer = - new GenericContainer<>(DockerImageName.parse(DOCKER_IMAGE)) - .withNetwork(network) - .withEnv("SOLR_URL", "http://solr:8983/solr/") - .withEnv("SPRING_DOCKER_COMPOSE_ENABLED", "false") - .withEnv("PROFILES", "http") - .withExposedPorts(HTTP_PORT) - .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MCP-SERVER-HTTP")) - // Wait for HTTP endpoint to be ready - .waitingFor( - Wait.forHttp("/actuator/health") - .forPort(HTTP_PORT) - .withStartupTimeout(Duration.ofSeconds(60))); - - private static HttpClient httpClient; - private static String baseUrl; - - @BeforeAll - static void setup() { - log.info("Solr container started. Internal URL: http://solr:8983/solr/"); - log.info("MCP Server container started in HTTP mode"); - - // Initialize HTTP client - httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); - - // Get the mapped port for accessing the container from the host - Integer mappedPort = mcpServerContainer.getMappedPort(HTTP_PORT); - baseUrl = "http://localhost:" + mappedPort; - - log.info("MCP Server HTTP endpoint available at: {}", baseUrl); - } - - @Test - void testSolrContainerIsRunning() { - // Verify Solr container started successfully - // This is essential because MCP server depends on Solr being available - assertTrue(solrContainer.isRunning(), "Solr container should be running"); - - log.info("Solr container is running and available at http://solr:8983/solr/"); - } - - @Test - void testContainerStartsAndRemainsStable() { - // Verify initial startup - assertTrue(mcpServerContainer.isRunning(), "Container should start successfully"); - - // Monitor container stability over 10 seconds to ensure it doesn't crash - await().atMost(10, TimeUnit.SECONDS) - .pollInterval(1, TimeUnit.SECONDS) - .pollDelay(Duration.ZERO) - .untilAsserted(() -> assertTrue(mcpServerContainer.isRunning())); - - log.info("Container started successfully and remained stable for 10 seconds"); - } - - @Test - void testNoErrorsInLogs() { - String logs = mcpServerContainer.getLogs(); - - // Check for critical error patterns - assertFalse( - logs.contains("Exception in thread \"main\""), - "Logs should not contain main thread exceptions"); - - assertFalse( - logs.contains("Application run failed"), - "Logs should not contain application failure messages"); - - assertFalse( - logs.contains("ERROR") && logs.contains("Failed to start"), - "Logs should not contain startup failure errors"); - - assertFalse( - logs.contains("fatal error") || logs.contains("JVM crash"), - "Logs should not contain JVM crash messages"); - - log.info("No critical errors found in container logs"); - } - - @Test - void testHttpEndpointResponds() throws IOException, InterruptedException { - // Test that the HTTP endpoint is accessible - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl + "/actuator/health")) - .timeout(Duration.ofSeconds(10)) - .GET() - .build(); - - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - assertEquals(200, response.statusCode(), "Health endpoint should return 200 OK"); - assertTrue( - response.body().contains("UP") || response.body().contains("\"status\""), - "Health endpoint should return UP status or status field"); - - log.info("HTTP endpoint responded successfully with status: {}", response.statusCode()); - } - - @Test - void testSolrConnectivity() { - // Verify environment variables are working and Solr is accessible - String logs = mcpServerContainer.getLogs(); - - assertFalse( - logs.contains("Connection refused"), - "Logs should not contain connection refused errors"); - - assertFalse( - logs.contains("UnknownHostException"), - "Logs should not contain unknown host exceptions"); - - log.info("Container can connect to Solr without errors"); - } - - @Test - void testHttpModeConfiguration() { - String logs = mcpServerContainer.getLogs(); - - // Verify HTTP mode is active by checking for typical Spring Boot web server logs - assertTrue( - logs.contains("Tomcat started on port") || logs.contains("Netty started on port"), - "Logs should indicate web server started on a port"); - - log.info("HTTP mode configuration verified"); - } - - @Test - void testPortExposure() { - // Verify the port is exposed and mapped - Integer mappedPort = mcpServerContainer.getMappedPort(HTTP_PORT); - assertNotNull(mappedPort, "HTTP port should be exposed and mapped"); - assertTrue(mappedPort > 0, "Mapped port should be a valid port number"); - - log.info("Port {} is properly exposed and mapped to {}", HTTP_PORT, mappedPort); - } + private static final Logger log = LoggerFactory.getLogger(DockerImageHttpIntegrationTest.class); + + // Docker image name and tag from build-info.properties + private static final String DOCKER_IMAGE = BuildInfoReader.getDockerImageName(); + private static final String SOLR_IMAGE = "solr:9.9-slim"; + private static final int HTTP_PORT = 8080; + + // Network for container communication + private static final Network network = Network.newNetwork(); + + // Solr container for backend + // Note: This field is used implicitly through the @Container annotation. + // Testcontainers JUnit extension automatically: + // 1. Starts this container before tests run + // 2. Makes it accessible via network alias "solr" at http://solr:8983/solr/ + // 3. Stops and cleans up the container after tests complete + @Container + private static final SolrContainer solrContainer = new SolrContainer(DockerImageName.parse(SOLR_IMAGE)) + .withNetwork(network).withNetworkAliases("solr") + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("SOLR")); + + // MCP Server container (the image we're testing) + // Note: In HTTP mode, the application exposes a web server on port 8080 + @Container + private static final GenericContainer mcpServerContainer = new GenericContainer<>( + DockerImageName.parse(DOCKER_IMAGE)).withNetwork(network).withEnv("SOLR_URL", "http://solr:8983/solr/") + .withEnv("SPRING_DOCKER_COMPOSE_ENABLED", "false").withEnv("PROFILES", "http").withExposedPorts(HTTP_PORT) + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MCP-SERVER-HTTP")) + // Wait for HTTP endpoint to be ready + .waitingFor(Wait.forHttp("/actuator/health").forPort(HTTP_PORT).withStartupTimeout(Duration.ofSeconds(60))); + + private static HttpClient httpClient; + private static String baseUrl; + + @BeforeAll + static void setup() { + log.info("Solr container started. Internal URL: http://solr:8983/solr/"); + log.info("MCP Server container started in HTTP mode"); + + // Initialize HTTP client + httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + // Get the mapped port for accessing the container from the host + Integer mappedPort = mcpServerContainer.getMappedPort(HTTP_PORT); + baseUrl = "http://localhost:" + mappedPort; + + log.info("MCP Server HTTP endpoint available at: {}", baseUrl); + } + + @Test + void testSolrContainerIsRunning() { + // Verify Solr container started successfully + // This is essential because MCP server depends on Solr being available + assertTrue(solrContainer.isRunning(), "Solr container should be running"); + + log.info("Solr container is running and available at http://solr:8983/solr/"); + } + + @Test + void testContainerStartsAndRemainsStable() { + // Verify initial startup + assertTrue(mcpServerContainer.isRunning(), "Container should start successfully"); + + // Monitor container stability over 10 seconds to ensure it doesn't crash + await().atMost(10, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).pollDelay(Duration.ZERO) + .untilAsserted(() -> assertTrue(mcpServerContainer.isRunning())); + + log.info("Container started successfully and remained stable for 10 seconds"); + } + + @Test + void testNoErrorsInLogs() { + String logs = mcpServerContainer.getLogs(); + + // Check for critical error patterns + assertFalse(logs.contains("Exception in thread \"main\""), "Logs should not contain main thread exceptions"); + + assertFalse(logs.contains("Application run failed"), "Logs should not contain application failure messages"); + + assertFalse(logs.contains("ERROR") && logs.contains("Failed to start"), + "Logs should not contain startup failure errors"); + + assertFalse(logs.contains("fatal error") || logs.contains("JVM crash"), + "Logs should not contain JVM crash messages"); + + log.info("No critical errors found in container logs"); + } + + @Test + void testHttpEndpointResponds() throws IOException, InterruptedException { + // Test that the HTTP endpoint is accessible + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/actuator/health")) + .timeout(Duration.ofSeconds(10)).GET().build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), "Health endpoint should return 200 OK"); + assertTrue(response.body().contains("UP") || response.body().contains("\"status\""), + "Health endpoint should return UP status or status field"); + + log.info("HTTP endpoint responded successfully with status: {}", response.statusCode()); + } + + @Test + void testSolrConnectivity() { + // Verify environment variables are working and Solr is accessible + String logs = mcpServerContainer.getLogs(); + + assertFalse(logs.contains("Connection refused"), "Logs should not contain connection refused errors"); + + assertFalse(logs.contains("UnknownHostException"), "Logs should not contain unknown host exceptions"); + + log.info("Container can connect to Solr without errors"); + } + + @Test + void testHttpModeConfiguration() { + String logs = mcpServerContainer.getLogs(); + + // Verify HTTP mode is active by checking for typical Spring Boot web server + // logs + assertTrue(logs.contains("Tomcat started on port") || logs.contains("Netty started on port"), + "Logs should indicate web server started on a port"); + + log.info("HTTP mode configuration verified"); + } + + @Test + void testPortExposure() { + // Verify the port is exposed and mapped + Integer mappedPort = mcpServerContainer.getMappedPort(HTTP_PORT); + assertNotNull(mappedPort, "HTTP port should be exposed and mapped"); + assertTrue(mappedPort > 0, "Mapped port should be a valid port number"); + + log.info("Port {} is properly exposed and mapped to {}", HTTP_PORT, mappedPort); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java index e8e73a5..832aaee 100644 --- a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java @@ -39,153 +39,143 @@ /** * Integration test for the Docker image produced by Jib running in STDIO mode. * - *

This test verifies that the Docker image built by Jib: + *

+ * This test verifies that the Docker image built by Jib: * *

    - *
  • Starts successfully without errors in STDIO mode - *
  • Runs the Spring Boot MCP server application correctly - *
  • Doesn't crash during initial startup period - *
  • Can connect to an external Solr instance + *
  • Starts successfully without errors in STDIO mode + *
  • Runs the Spring Boot MCP server application correctly + *
  • Doesn't crash during initial startup period + *
  • Can connect to an external Solr instance *
* - *

Prerequisites: Before running this test, you must build the Docker image: + *

+ * Prerequisites: Before running this test, you must build the + * Docker image: * *

{@code
  * ./gradlew jibDockerBuild
  * }
* - *

The image name and version are read from {@code META-INF/build-info.properties} + *

+ * The image name and version are read from + * {@code META-INF/build-info.properties} * - *

Test Architecture: + *

+ * Test Architecture: * *

    - *
  1. Creates a shared Docker network for inter-container communication - *
  2. Starts a Solr container on the network - *
  3. Starts the MCP server Docker image in STDIO mode with connection to Solr - *
  4. Verifies the container starts and remains stable - *
  5. Validates container health over time + *
  6. Creates a shared Docker network for inter-container communication + *
  7. Starts a Solr container on the network + *
  8. Starts the MCP server Docker image in STDIO mode with connection to Solr + *
  9. Verifies the container starts and remains stable + *
  10. Validates container health over time *
* - *

Note: This test is tagged with "docker-integration" and is designed to run - * separately from regular unit tests using the {@code dockerIntegrationTest} Gradle task. + *

+ * Note: This test is tagged with "docker-integration" and is + * designed to run separately from regular unit tests using the + * {@code dockerIntegrationTest} Gradle task. */ @Testcontainers @Tag("docker-integration") class DockerImageStdioIntegrationTest { - private static final Logger log = - LoggerFactory.getLogger(DockerImageStdioIntegrationTest.class); - - // Docker image name and tag from build-info.properties - private static final String DOCKER_IMAGE = BuildInfoReader.getDockerImageName(); - private static final String SOLR_IMAGE = "solr:9.9-slim"; - - // Network for container communication - private static final Network network = Network.newNetwork(); - - // Solr container for backend - // Note: This field is used implicitly through the @Container annotation. - // Testcontainers JUnit extension automatically: - // 1. Starts this container before tests run - // 2. Makes it accessible via network alias "solr" at http://solr:8983/solr/ - // 3. Stops and cleans up the container after tests complete - @Container - private static final SolrContainer solrContainer = - new SolrContainer(DockerImageName.parse(SOLR_IMAGE)) - .withNetwork(network) - .withNetworkAliases("solr") - .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("SOLR")); - - // MCP Server container (the image we're testing) - // Note: In STDIO mode, the application doesn't produce logs to stdout that we can wait for, - // so we use a simple startup delay and then verify the container is running - @Container - private static final GenericContainer mcpServerContainer = - new GenericContainer<>(DockerImageName.parse(DOCKER_IMAGE)) - .withNetwork(network) - .withEnv("SOLR_URL", "http://solr:8983/solr/") - .withEnv("SPRING_DOCKER_COMPOSE_ENABLED", "false") - .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MCP-SERVER")) - // Give the application time to start (STDIO mode doesn't produce logs to wait - // for) - .withStartupTimeout(Duration.ofSeconds(60)); - - @BeforeAll - static void setup() throws InterruptedException { - log.info("Solr container started. Internal URL: http://solr:8983/solr/"); - log.info("MCP Server container starting. Waiting for initialization..."); - - // Give the MCP server a few seconds to initialize - // In STDIO mode, the app runs but doesn't produce logs we can monitor - Thread.sleep(5000); - - log.info("Initialization wait complete. Beginning tests."); - } - - @Test - void testSolrContainerIsRunning() { - // Verify Solr container started successfully - // This is essential because MCP server depends on Solr being available - assertTrue(solrContainer.isRunning(), "Solr container should be running"); - - log.info("Solr container is running and available at http://solr:8983/solr/"); - } - - @Test - void testContainerStartsAndRemainsStable() { - // Verify initial startup - assertTrue(mcpServerContainer.isRunning(), "Container should start successfully"); - - // Monitor container stability over 10 seconds to ensure it doesn't crash - await().atMost(10, TimeUnit.SECONDS) - .pollInterval(1, TimeUnit.SECONDS) - .pollDelay(Duration.ZERO) - .untilAsserted(() -> assertTrue(mcpServerContainer.isRunning())); - - log.info("Container started successfully and remained stable for 10 seconds"); - } - - @Test - void testNoErrorsInLogs() { - String logs = mcpServerContainer.getLogs(); - - // Check for critical error patterns - assertFalse( - logs.contains("Exception in thread \"main\""), - "Logs should not contain main thread exceptions"); - - assertFalse( - logs.contains("Application run failed"), - "Logs should not contain application failure messages"); - - assertFalse( - logs.contains("ERROR") && logs.contains("Failed to start"), - "Logs should not contain startup failure errors"); - - assertFalse( - logs.contains("fatal error") || logs.contains("JVM crash"), - "Logs should not contain JVM crash messages"); - - assertFalse( - logs.contains("exec format error"), - "Logs should not contain platform compatibility errors"); - - log.info("No critical errors found in container logs"); - } - - @Test - void testSolrConnectivity() { - // Verify environment variables are working and Solr is accessible - String logs = mcpServerContainer.getLogs(); - - assertFalse( - logs.contains("Connection refused"), - "Logs should not contain connection refused errors"); - - assertFalse( - logs.contains("UnknownHostException"), - "Logs should not contain unknown host exceptions"); - - log.info("Container can connect to Solr without errors"); - } + private static final Logger log = LoggerFactory.getLogger(DockerImageStdioIntegrationTest.class); + + // Docker image name and tag from build-info.properties + private static final String DOCKER_IMAGE = BuildInfoReader.getDockerImageName(); + private static final String SOLR_IMAGE = "solr:9.9-slim"; + + // Network for container communication + private static final Network network = Network.newNetwork(); + + // Solr container for backend + // Note: This field is used implicitly through the @Container annotation. + // Testcontainers JUnit extension automatically: + // 1. Starts this container before tests run + // 2. Makes it accessible via network alias "solr" at http://solr:8983/solr/ + // 3. Stops and cleans up the container after tests complete + @Container + private static final SolrContainer solrContainer = new SolrContainer(DockerImageName.parse(SOLR_IMAGE)) + .withNetwork(network).withNetworkAliases("solr") + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("SOLR")); + + // MCP Server container (the image we're testing) + // Note: In STDIO mode, the application doesn't produce logs to stdout that we + // can wait for, + // so we use a simple startup delay and then verify the container is running + @Container + private static final GenericContainer mcpServerContainer = new GenericContainer<>( + DockerImageName.parse(DOCKER_IMAGE)).withNetwork(network).withEnv("SOLR_URL", "http://solr:8983/solr/") + .withEnv("SPRING_DOCKER_COMPOSE_ENABLED", "false") + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MCP-SERVER")) + // Give the application time to start (STDIO mode doesn't produce logs to wait + // for) + .withStartupTimeout(Duration.ofSeconds(60)); + + @BeforeAll + static void setup() throws InterruptedException { + log.info("Solr container started. Internal URL: http://solr:8983/solr/"); + log.info("MCP Server container starting. Waiting for initialization..."); + + // Give the MCP server a few seconds to initialize + // In STDIO mode, the app runs but doesn't produce logs we can monitor + Thread.sleep(5000); + + log.info("Initialization wait complete. Beginning tests."); + } + + @Test + void testSolrContainerIsRunning() { + // Verify Solr container started successfully + // This is essential because MCP server depends on Solr being available + assertTrue(solrContainer.isRunning(), "Solr container should be running"); + + log.info("Solr container is running and available at http://solr:8983/solr/"); + } + + @Test + void testContainerStartsAndRemainsStable() { + // Verify initial startup + assertTrue(mcpServerContainer.isRunning(), "Container should start successfully"); + + // Monitor container stability over 10 seconds to ensure it doesn't crash + await().atMost(10, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).pollDelay(Duration.ZERO) + .untilAsserted(() -> assertTrue(mcpServerContainer.isRunning())); + + log.info("Container started successfully and remained stable for 10 seconds"); + } + + @Test + void testNoErrorsInLogs() { + String logs = mcpServerContainer.getLogs(); + + // Check for critical error patterns + assertFalse(logs.contains("Exception in thread \"main\""), "Logs should not contain main thread exceptions"); + + assertFalse(logs.contains("Application run failed"), "Logs should not contain application failure messages"); + + assertFalse(logs.contains("ERROR") && logs.contains("Failed to start"), + "Logs should not contain startup failure errors"); + + assertFalse(logs.contains("fatal error") || logs.contains("JVM crash"), + "Logs should not contain JVM crash messages"); + + assertFalse(logs.contains("exec format error"), "Logs should not contain platform compatibility errors"); + + log.info("No critical errors found in container logs"); + } + + @Test + void testSolrConnectivity() { + // Verify environment variables are working and Solr is accessible + String logs = mcpServerContainer.getLogs(); + + assertFalse(logs.contains("Connection refused"), "Logs should not contain connection refused errors"); + + assertFalse(logs.contains("UnknownHostException"), "Logs should not contain unknown host exceptions"); + + log.info("Container can connect to Solr without errors"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java index b22c83c..59fd875 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java @@ -29,126 +29,122 @@ /** * Test class for CSV indexing functionality in IndexingService. * - *

This test verifies that the IndexingService can correctly parse CSV data and convert it into - * SolrInputDocument objects using the schema-less approach. + *

+ * This test verifies that the IndexingService can correctly parse CSV data and + * convert it into SolrInputDocument objects using the schema-less approach. */ @SpringBootTest @TestPropertySource(locations = "classpath:application.properties") class CsvIndexingTest { - @Autowired private IndexingDocumentCreator indexingDocumentCreator; - - @Test - void testCreateSchemalessDocumentsFromCsv() throws Exception { - // Given - - String csvData = - """ -id,cat,name,price,inStock,author,series_t,sequence_i,genre_s -0553573403,book,A Game of Thrones,7.99,true,George R.R. Martin,"A Song of Ice and Fire",1,fantasy -0553579908,book,A Clash of Kings,7.99,true,George R.R. Martin,"A Song of Ice and Fire",2,fantasy -0553293354,book,Foundation,7.99,true,Isaac Asimov,Foundation Novels,1,scifi -"""; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); - - // Then - assertThat(documents).hasSize(3); - - // Verify first document - SolrInputDocument firstDoc = documents.getFirst(); - assertThat(firstDoc.getFieldValue("id")).isEqualTo("0553573403"); - assertThat(firstDoc.getFieldValue("cat")).isEqualTo("book"); - assertThat(firstDoc.getFieldValue("name")).isEqualTo("A Game of Thrones"); - assertThat(firstDoc.getFieldValue("price")).isEqualTo("7.99"); - assertThat(firstDoc.getFieldValue("instock")).isEqualTo("true"); - assertThat(firstDoc.getFieldValue("author")).isEqualTo("George R.R. Martin"); - assertThat(firstDoc.getFieldValue("series_t")).isEqualTo("A Song of Ice and Fire"); - assertThat(firstDoc.getFieldValue("sequence_i")).isEqualTo("1"); - assertThat(firstDoc.getFieldValue("genre_s")).isEqualTo("fantasy"); - - // Verify second document - SolrInputDocument secondDoc = documents.get(1); - assertThat(secondDoc.getFieldValue("id")).isEqualTo("0553579908"); - assertThat(secondDoc.getFieldValue("name")).isEqualTo("A Clash of Kings"); - assertThat(secondDoc.getFieldValue("sequence_i")).isEqualTo("2"); - - // Verify third document - SolrInputDocument thirdDoc = documents.get(2); - assertThat(thirdDoc.getFieldValue("id")).isEqualTo("0553293354"); - assertThat(thirdDoc.getFieldValue("name")).isEqualTo("Foundation"); - assertThat(thirdDoc.getFieldValue("author")).isEqualTo("Isaac Asimov"); - assertThat(thirdDoc.getFieldValue("genre_s")).isEqualTo("scifi"); - } - - @Test - void testCreateSchemalessDocumentsFromCsvWithEmptyValues() throws Exception { - // Given - - String csvData = - """ - id,name,description - 1,Test Product,Some description - 2,Another Product, - 3,,Empty name - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); - - // Then - assertThat(documents).hasSize(3); - - // First document should have all fields - SolrInputDocument firstDoc = documents.getFirst(); - assertThat(firstDoc.getFieldValue("id")).isEqualTo("1"); - assertThat(firstDoc.getFieldValue("name")).isEqualTo("Test Product"); - assertThat(firstDoc.getFieldValue("description")).isEqualTo("Some description"); - - // Second document should skip empty description - SolrInputDocument secondDoc = documents.get(1); - assertThat(secondDoc.getFieldValue("id")).isEqualTo("2"); - assertThat(secondDoc.getFieldValue("name")).isEqualTo("Another Product"); - assertThat(secondDoc.getFieldValue("description")).isNull(); - - // Third document should skip empty name - SolrInputDocument thirdDoc = documents.get(2); - assertThat(thirdDoc.getFieldValue("id")).isEqualTo("3"); - assertThat(thirdDoc.getFieldValue("name")).isNull(); - assertThat(thirdDoc.getFieldValue("description")).isEqualTo("Empty name"); - } - - @Test - void testCreateSchemalessDocumentsFromCsvWithQuotedValues() throws Exception { - // Given - - String csvData = - """ - id,name,description - 1,"Quoted Name","Quoted description" - 2,Regular Name,Regular description - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); - - // Then - assertThat(documents).hasSize(2); - - // First document should have quotes removed - SolrInputDocument firstDoc = documents.getFirst(); - assertThat(firstDoc.getFieldValue("id")).isEqualTo("1"); - assertThat(firstDoc.getFieldValue("name")).isEqualTo("Quoted Name"); - assertThat(firstDoc.getFieldValue("description")).isEqualTo("Quoted description"); - - // Second document should remain unchanged - SolrInputDocument secondDoc = documents.get(1); - assertThat(secondDoc.getFieldValue("id")).isEqualTo("2"); - assertThat(secondDoc.getFieldValue("name")).isEqualTo("Regular Name"); - assertThat(secondDoc.getFieldValue("description")).isEqualTo("Regular description"); - } + @Autowired + private IndexingDocumentCreator indexingDocumentCreator; + + @Test + void testCreateSchemalessDocumentsFromCsv() throws Exception { + // Given + + String csvData = """ + id,cat,name,price,inStock,author,series_t,sequence_i,genre_s + 0553573403,book,A Game of Thrones,7.99,true,George R.R. Martin,"A Song of Ice and Fire",1,fantasy + 0553579908,book,A Clash of Kings,7.99,true,George R.R. Martin,"A Song of Ice and Fire",2,fantasy + 0553293354,book,Foundation,7.99,true,Isaac Asimov,Foundation Novels,1,scifi + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); + + // Then + assertThat(documents).hasSize(3); + + // Verify first document + SolrInputDocument firstDoc = documents.getFirst(); + assertThat(firstDoc.getFieldValue("id")).isEqualTo("0553573403"); + assertThat(firstDoc.getFieldValue("cat")).isEqualTo("book"); + assertThat(firstDoc.getFieldValue("name")).isEqualTo("A Game of Thrones"); + assertThat(firstDoc.getFieldValue("price")).isEqualTo("7.99"); + assertThat(firstDoc.getFieldValue("instock")).isEqualTo("true"); + assertThat(firstDoc.getFieldValue("author")).isEqualTo("George R.R. Martin"); + assertThat(firstDoc.getFieldValue("series_t")).isEqualTo("A Song of Ice and Fire"); + assertThat(firstDoc.getFieldValue("sequence_i")).isEqualTo("1"); + assertThat(firstDoc.getFieldValue("genre_s")).isEqualTo("fantasy"); + + // Verify second document + SolrInputDocument secondDoc = documents.get(1); + assertThat(secondDoc.getFieldValue("id")).isEqualTo("0553579908"); + assertThat(secondDoc.getFieldValue("name")).isEqualTo("A Clash of Kings"); + assertThat(secondDoc.getFieldValue("sequence_i")).isEqualTo("2"); + + // Verify third document + SolrInputDocument thirdDoc = documents.get(2); + assertThat(thirdDoc.getFieldValue("id")).isEqualTo("0553293354"); + assertThat(thirdDoc.getFieldValue("name")).isEqualTo("Foundation"); + assertThat(thirdDoc.getFieldValue("author")).isEqualTo("Isaac Asimov"); + assertThat(thirdDoc.getFieldValue("genre_s")).isEqualTo("scifi"); + } + + @Test + void testCreateSchemalessDocumentsFromCsvWithEmptyValues() throws Exception { + // Given + + String csvData = """ + id,name,description + 1,Test Product,Some description + 2,Another Product, + 3,,Empty name + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); + + // Then + assertThat(documents).hasSize(3); + + // First document should have all fields + SolrInputDocument firstDoc = documents.getFirst(); + assertThat(firstDoc.getFieldValue("id")).isEqualTo("1"); + assertThat(firstDoc.getFieldValue("name")).isEqualTo("Test Product"); + assertThat(firstDoc.getFieldValue("description")).isEqualTo("Some description"); + + // Second document should skip empty description + SolrInputDocument secondDoc = documents.get(1); + assertThat(secondDoc.getFieldValue("id")).isEqualTo("2"); + assertThat(secondDoc.getFieldValue("name")).isEqualTo("Another Product"); + assertThat(secondDoc.getFieldValue("description")).isNull(); + + // Third document should skip empty name + SolrInputDocument thirdDoc = documents.get(2); + assertThat(thirdDoc.getFieldValue("id")).isEqualTo("3"); + assertThat(thirdDoc.getFieldValue("name")).isNull(); + assertThat(thirdDoc.getFieldValue("description")).isEqualTo("Empty name"); + } + + @Test + void testCreateSchemalessDocumentsFromCsvWithQuotedValues() throws Exception { + // Given + + String csvData = """ + id,name,description + 1,"Quoted Name","Quoted description" + 2,Regular Name,Regular description + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); + + // Then + assertThat(documents).hasSize(2); + + // First document should have quotes removed + SolrInputDocument firstDoc = documents.getFirst(); + assertThat(firstDoc.getFieldValue("id")).isEqualTo("1"); + assertThat(firstDoc.getFieldValue("name")).isEqualTo("Quoted Name"); + assertThat(firstDoc.getFieldValue("description")).isEqualTo("Quoted description"); + + // Second document should remain unchanged + SolrInputDocument secondDoc = documents.get(1); + assertThat(secondDoc.getFieldValue("id")).isEqualTo("2"); + assertThat(secondDoc.getFieldValue("name")).isEqualTo("Regular Name"); + assertThat(secondDoc.getFieldValue("description")).isEqualTo("Regular description"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java index d1b9819..4d57b34 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java @@ -35,188 +35,178 @@ @ExtendWith(MockitoExtension.class) class IndexingServiceDirectTest { - @Mock private SolrClient solrClient; - - @Mock private UpdateResponse updateResponse; - - private IndexingService indexingService; - private IndexingDocumentCreator indexingDocumentCreator; - - @BeforeEach - void setUp() { - indexingDocumentCreator = - new IndexingDocumentCreator( - new XmlDocumentCreator(), - new CsvDocumentCreator(), - new JsonDocumentCreator()); - indexingService = new IndexingService(solrClient, indexingDocumentCreator); - } - - @Test - void testBatchIndexingErrorHandling() throws Exception { - // Create a list of test documents - List documents = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - SolrInputDocument doc = new SolrInputDocument(); - doc.addField("id", "test" + i); - doc.addField("title", "Test Document " + i); - documents.add(doc); - } - - // Mock behavior: Batch add fails, but individual adds succeed - when(solrClient.add(anyString(), anyList())) - .thenThrow(new RuntimeException("Batch indexing failed")); - - // Individual document adds should succeed - when(solrClient.add(anyString(), any(SolrInputDocument.class))).thenReturn(updateResponse); - - // Call the method under test - int successCount = indexingService.indexDocuments("test_collection", documents); - - // Verify the results - assertEquals(10, successCount, "All documents should be successfully indexed individually"); - - // Verify that batch add was attempted once - verify(solrClient, times(1)).add(eq("test_collection"), anyList()); - - // Verify that individual adds were attempted for each document - verify(solrClient, times(10)).add(eq("test_collection"), any(SolrInputDocument.class)); - - // Verify that commit was called - verify(solrClient, times(1)).commit("test_collection"); - } - - @Test - void testBatchIndexingPartialFailure() throws Exception { - // Create a list of test documents - List documents = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - SolrInputDocument doc = new SolrInputDocument(); - doc.addField("id", "test" + i); - doc.addField("title", "Test Document " + i); - documents.add(doc); - } - - // Mock behavior: Batch add fails - when(solrClient.add(anyString(), anyList())) - .thenThrow(new RuntimeException("Batch indexing failed")); - - // Even-numbered documents succeed, odd-numbered documents fail - for (int i = 0; i < 10; i++) { - if (i % 2 == 0) { - when(solrClient.add("test_collection", documents.get(i))) - .thenReturn(updateResponse); - } else { - when(solrClient.add("test_collection", documents.get(i))) - .thenThrow(new RuntimeException("Document " + i + " indexing failed")); - } - } - - // Call the method under test - int successCount = indexingService.indexDocuments("test_collection", documents); - - // Verify the results - only even-numbered documents should succeed - assertEquals(5, successCount, "Only half of the documents should be successfully indexed"); - - // Verify that batch add was attempted once - verify(solrClient, times(1)).add(eq("test_collection"), anyList()); - - // Verify that individual adds were attempted for each document - verify(solrClient, times(10)).add(eq("test_collection"), any(SolrInputDocument.class)); - - // Verify that commit was called - verify(solrClient, times(1)).commit("test_collection"); - } - - @Test - void testIndexJsonDocumentsWithJsonString() throws Exception { - // Test JSON string with multiple documents - String json = - """ - [ - { - "id": "test001", - "title": "Test Document 1", - "content": "This is test content 1" - }, - { - "id": "test002", - "title": "Test Document 2", - "content": "This is test content 2" - } - ] - """; - - // Create a spy on the indexingDocumentCreator and inject it into a new IndexingService - IndexingDocumentCreator indexingDocumentCreatorSpy = spy(indexingDocumentCreator); - IndexingService indexingServiceWithSpy = - new IndexingService(solrClient, indexingDocumentCreatorSpy); - IndexingService indexingServiceSpy = spy(indexingServiceWithSpy); - - // Create mock documents that would be returned by createSchemalessDocuments - List mockDocuments = new ArrayList<>(); - SolrInputDocument doc1 = new SolrInputDocument(); - doc1.addField("id", "test001"); - doc1.addField("title", "Test Document 1"); - doc1.addField("content", "This is test content 1"); - - SolrInputDocument doc2 = new SolrInputDocument(); - doc2.addField("id", "test002"); - doc2.addField("title", "Test Document 2"); - doc2.addField("content", "This is test content 2"); - - mockDocuments.add(doc1); - mockDocuments.add(doc2); - - // Mock the createSchemalessDocuments method to return our mock documents - doReturn(mockDocuments) - .when(indexingDocumentCreatorSpy) - .createSchemalessDocumentsFromJson(json); - - // Mock the indexDocuments method that takes a collection and list of documents - doReturn(2).when(indexingServiceSpy).indexDocuments(anyString(), anyList()); - - // Call the method under test - indexingServiceSpy.indexJsonDocuments("test_collection", json); - - // Verify that createSchemalessDocuments was called with the JSON string - verify(indexingDocumentCreatorSpy, times(1)).createSchemalessDocumentsFromJson(json); - - // Verify that indexDocuments was called with the collection name and the documents - verify(indexingServiceSpy, times(1)).indexDocuments("test_collection", mockDocuments); - } - - @Test - void testIndexJsonDocumentsWithJsonStringErrorHandling() throws Exception { - // Test JSON string with invalid format - String invalidJson = "{ This is not valid JSON }"; - - // Create a spy on the indexingDocumentCreator and inject it into a new IndexingService - IndexingDocumentCreator indexingDocumentCreatorSpy = spy(indexingDocumentCreator); - IndexingService indexingServiceWithSpy = - new IndexingService(solrClient, indexingDocumentCreatorSpy); - IndexingService indexingServiceSpy = spy(indexingServiceWithSpy); - - // Mock the createSchemalessDocuments method to throw an exception - doThrow(new DocumentProcessingException("Invalid JSON")) - .when(indexingDocumentCreatorSpy) - .createSchemalessDocumentsFromJson(invalidJson); - - // Call the method under test and verify it throws an exception - DocumentProcessingException exception = - assertThrows( - DocumentProcessingException.class, - () -> { - indexingServiceSpy.indexJsonDocuments("test_collection", invalidJson); - }); - - // Verify the exception message - assertTrue(exception.getMessage().contains("Invalid JSON")); - - // Verify that createSchemalessDocuments was called - verify(indexingDocumentCreatorSpy, times(1)).createSchemalessDocumentsFromJson(invalidJson); - - // Verify that indexDocuments with documents was not called - verify(indexingServiceSpy, never()).indexDocuments(anyString(), anyList()); - } + @Mock + private SolrClient solrClient; + + @Mock + private UpdateResponse updateResponse; + + private IndexingService indexingService; + private IndexingDocumentCreator indexingDocumentCreator; + + @BeforeEach + void setUp() { + indexingDocumentCreator = new IndexingDocumentCreator(new XmlDocumentCreator(), new CsvDocumentCreator(), + new JsonDocumentCreator()); + indexingService = new IndexingService(solrClient, indexingDocumentCreator); + } + + @Test + void testBatchIndexingErrorHandling() throws Exception { + // Create a list of test documents + List documents = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "test" + i); + doc.addField("title", "Test Document " + i); + documents.add(doc); + } + + // Mock behavior: Batch add fails, but individual adds succeed + when(solrClient.add(anyString(), anyList())).thenThrow(new RuntimeException("Batch indexing failed")); + + // Individual document adds should succeed + when(solrClient.add(anyString(), any(SolrInputDocument.class))).thenReturn(updateResponse); + + // Call the method under test + int successCount = indexingService.indexDocuments("test_collection", documents); + + // Verify the results + assertEquals(10, successCount, "All documents should be successfully indexed individually"); + + // Verify that batch add was attempted once + verify(solrClient, times(1)).add(eq("test_collection"), anyList()); + + // Verify that individual adds were attempted for each document + verify(solrClient, times(10)).add(eq("test_collection"), any(SolrInputDocument.class)); + + // Verify that commit was called + verify(solrClient, times(1)).commit("test_collection"); + } + + @Test + void testBatchIndexingPartialFailure() throws Exception { + // Create a list of test documents + List documents = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "test" + i); + doc.addField("title", "Test Document " + i); + documents.add(doc); + } + + // Mock behavior: Batch add fails + when(solrClient.add(anyString(), anyList())).thenThrow(new RuntimeException("Batch indexing failed")); + + // Even-numbered documents succeed, odd-numbered documents fail + for (int i = 0; i < 10; i++) { + if (i % 2 == 0) { + when(solrClient.add("test_collection", documents.get(i))).thenReturn(updateResponse); + } else { + when(solrClient.add("test_collection", documents.get(i))) + .thenThrow(new RuntimeException("Document " + i + " indexing failed")); + } + } + + // Call the method under test + int successCount = indexingService.indexDocuments("test_collection", documents); + + // Verify the results - only even-numbered documents should succeed + assertEquals(5, successCount, "Only half of the documents should be successfully indexed"); + + // Verify that batch add was attempted once + verify(solrClient, times(1)).add(eq("test_collection"), anyList()); + + // Verify that individual adds were attempted for each document + verify(solrClient, times(10)).add(eq("test_collection"), any(SolrInputDocument.class)); + + // Verify that commit was called + verify(solrClient, times(1)).commit("test_collection"); + } + + @Test + void testIndexJsonDocumentsWithJsonString() throws Exception { + // Test JSON string with multiple documents + String json = """ + [ + { + "id": "test001", + "title": "Test Document 1", + "content": "This is test content 1" + }, + { + "id": "test002", + "title": "Test Document 2", + "content": "This is test content 2" + } + ] + """; + + // Create a spy on the indexingDocumentCreator and inject it into a new + // IndexingService + IndexingDocumentCreator indexingDocumentCreatorSpy = spy(indexingDocumentCreator); + IndexingService indexingServiceWithSpy = new IndexingService(solrClient, indexingDocumentCreatorSpy); + IndexingService indexingServiceSpy = spy(indexingServiceWithSpy); + + // Create mock documents that would be returned by createSchemalessDocuments + List mockDocuments = new ArrayList<>(); + SolrInputDocument doc1 = new SolrInputDocument(); + doc1.addField("id", "test001"); + doc1.addField("title", "Test Document 1"); + doc1.addField("content", "This is test content 1"); + + SolrInputDocument doc2 = new SolrInputDocument(); + doc2.addField("id", "test002"); + doc2.addField("title", "Test Document 2"); + doc2.addField("content", "This is test content 2"); + + mockDocuments.add(doc1); + mockDocuments.add(doc2); + + // Mock the createSchemalessDocuments method to return our mock documents + doReturn(mockDocuments).when(indexingDocumentCreatorSpy).createSchemalessDocumentsFromJson(json); + + // Mock the indexDocuments method that takes a collection and list of documents + doReturn(2).when(indexingServiceSpy).indexDocuments(anyString(), anyList()); + + // Call the method under test + indexingServiceSpy.indexJsonDocuments("test_collection", json); + + // Verify that createSchemalessDocuments was called with the JSON string + verify(indexingDocumentCreatorSpy, times(1)).createSchemalessDocumentsFromJson(json); + + // Verify that indexDocuments was called with the collection name and the + // documents + verify(indexingServiceSpy, times(1)).indexDocuments("test_collection", mockDocuments); + } + + @Test + void testIndexJsonDocumentsWithJsonStringErrorHandling() throws Exception { + // Test JSON string with invalid format + String invalidJson = "{ This is not valid JSON }"; + + // Create a spy on the indexingDocumentCreator and inject it into a new + // IndexingService + IndexingDocumentCreator indexingDocumentCreatorSpy = spy(indexingDocumentCreator); + IndexingService indexingServiceWithSpy = new IndexingService(solrClient, indexingDocumentCreatorSpy); + IndexingService indexingServiceSpy = spy(indexingServiceWithSpy); + + // Mock the createSchemalessDocuments method to throw an exception + doThrow(new DocumentProcessingException("Invalid JSON")).when(indexingDocumentCreatorSpy) + .createSchemalessDocumentsFromJson(invalidJson); + + // Call the method under test and verify it throws an exception + DocumentProcessingException exception = assertThrows(DocumentProcessingException.class, () -> { + indexingServiceSpy.indexJsonDocuments("test_collection", invalidJson); + }); + + // Verify the exception message + assertTrue(exception.getMessage().contains("Invalid JSON")); + + // Verify that createSchemalessDocuments was called + verify(indexingDocumentCreatorSpy, times(1)).createSchemalessDocumentsFromJson(invalidJson); + + // Verify that indexDocuments with documents was not called + verify(indexingServiceSpy, never()).indexDocuments(anyString(), anyList()); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java index 0ca8b17..ae05a10 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java @@ -52,1042 +52,993 @@ @Import(TestcontainersConfiguration.class) class IndexingServiceTest { - private static boolean initialized = false; - - private static final String COLLECTION_NAME = "indexing_test_" + System.currentTimeMillis(); - @Autowired private SolrContainer solrContainer; - @Autowired private IndexingDocumentCreator indexingDocumentCreator; - @Autowired private IndexingService indexingService; - @Autowired private SearchService searchService; - @Autowired private SolrClient solrClient; - - @BeforeEach - void setUp() throws Exception { - - // Create processor instances and wire them manually since this is not a Spring Boot test - XmlDocumentCreator xmlDocumentCreator = new XmlDocumentCreator(); - CsvDocumentCreator csvDocumentCreator = new CsvDocumentCreator(); - JsonDocumentCreator jsonDocumentCreator = new JsonDocumentCreator(); - - indexingDocumentCreator = - new IndexingDocumentCreator( - xmlDocumentCreator, csvDocumentCreator, jsonDocumentCreator); - - indexingService = new IndexingService(solrClient, indexingDocumentCreator); - searchService = new SearchService(solrClient); - - if (!initialized) { - // Create collection - CollectionAdminRequest.Create createRequest = - CollectionAdminRequest.createCollection(COLLECTION_NAME, "_default", 1, 1); - createRequest.process(solrClient); - initialized = true; - } - } - - @Test - void testCreateSchemalessDocumentsFromJson() throws Exception { - // Test JSON string - String json = - """ - [ - { - "id": "test001", - "cat": ["book"], - "name": ["Test Book 1"], - "price": [9.99], - "inStock": [true], - "author": ["Test Author"], - "series_t": "Test Series", - "sequence_i": 1, - "genre_s": "test" - } - ] - """; - - // Create documents - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromJson(json); - - // Verify documents were created correctly - assertNotNull(documents); - assertEquals(1, documents.size()); - - SolrInputDocument doc = documents.getFirst(); - assertEquals("test001", doc.getFieldValue("id")); - - // Check field values - they might be stored directly or as collections - Object nameValue = doc.getFieldValue("name"); - if (nameValue instanceof List) { - assertEquals("Test Book 1", ((List) nameValue).getFirst()); - } else { - assertEquals("Test Book 1", nameValue); - } - - Object priceValue = doc.getFieldValue("price"); - if (priceValue instanceof List) { - assertEquals(9.99, ((List) priceValue).getFirst()); - } else { - assertEquals(9.99, priceValue); - } - - Object inStockValue = doc.getFieldValue("inStock"); - // Check if inStock field exists - if (inStockValue != null) { - if (inStockValue instanceof List) { - assertEquals(true, ((List) inStockValue).getFirst()); - } else { - assertEquals(true, inStockValue); - } - } else { - // If inStock is not present in the document, we'll skip this assertion - // Removed debug print statement - } - - Object authorValue = doc.getFieldValue("author"); - if (authorValue instanceof List) { - assertEquals("Test Author", ((List) authorValue).getFirst()); - } else { - assertEquals("Test Author", authorValue); - } - - assertEquals("Test Series", doc.getFieldValue("series_t")); - assertEquals(1, doc.getFieldValue("sequence_i")); - assertEquals("test", doc.getFieldValue("genre_s")); - } - - @Test - void testIndexJsonDocuments() throws Exception { - - // Test JSON string with multiple documents - String json = - """ - [ - { - "id": "test002", - "cat": ["book"], - "name": ["Test Book 2"], - "price": [19.99], - "inStock": [true], - "author": ["Test Author 2"], - "genre_s": "scifi" - }, - { - "id": "test003", - "cat": ["book"], - "name": ["Test Book 3"], - "price": [29.99], - "inStock": [false], - "author": ["Test Author 3"], - "genre_s": "fantasy" - } - ] - """; - - // Index documents - indexingService.indexJsonDocuments(COLLECTION_NAME, json); - - // Verify documents were indexed by searching for them - SearchResponse result = - searchService.search( - COLLECTION_NAME, "id:test002 OR id:test003", null, null, null, null, null); - - assertNotNull(result); - List> documents = result.documents(); - assertEquals(2, documents.size()); - - // Verify specific document fields - boolean foundBook2 = false; - boolean foundBook3 = false; - - for (Map book : documents) { - // Get ID and handle both String and List cases - Object idValue = book.get("id"); - String id; - if (idValue instanceof List) { - id = (String) ((List) idValue).getFirst(); - } else { - id = (String) idValue; - } - - if (id.equals("test002")) { - foundBook2 = true; - - // Handle name field - Object nameValue = book.get("name"); - if (nameValue instanceof List) { - assertEquals("Test Book 2", ((List) nameValue).getFirst()); - } else { - assertEquals("Test Book 2", nameValue); - } - - // Handle author field - Object authorValue = book.get("author"); - if (authorValue instanceof List) { - assertEquals("Test Author 2", ((List) authorValue).getFirst()); - } else { - assertEquals("Test Author 2", authorValue); - } - - // Handle genre field - Object genreValue = book.get("genre_s"); - if (genreValue instanceof List) { - assertEquals("scifi", ((List) genreValue).getFirst()); - } else { - assertEquals("scifi", genreValue); - } - } else if (id.equals("test003")) { - foundBook3 = true; - - // Handle name field - Object nameValue = book.get("name"); - if (nameValue instanceof List) { - assertEquals("Test Book 3", ((List) nameValue).getFirst()); - } else { - assertEquals("Test Book 3", nameValue); - } - - // Handle author field - Object authorValue = book.get("author"); - if (authorValue instanceof List) { - assertEquals("Test Author 3", ((List) authorValue).getFirst()); - } else { - assertEquals("Test Author 3", authorValue); - } - - // Handle genre field - Object genreValue = book.get("genre_s"); - if (genreValue instanceof List) { - assertEquals("fantasy", ((List) genreValue).getFirst()); - } else { - assertEquals("fantasy", genreValue); - } - } - } - - assertTrue(foundBook2, "Book 2 should be found in search results"); - assertTrue(foundBook3, "Book 3 should be found in search results"); - } - - @Test - void testIndexJsonDocumentsWithNestedObjects() throws Exception { - - // Test JSON string with nested objects - String json = - """ - [ - { - "id": "test004", - "cat": ["book"], - "name": ["Test Book 4"], - "price": [39.99], - "details": { - "publisher": "Test Publisher", - "year": 2023, - "edition": 1 - }, - "author": ["Test Author 4"] - } - ] - """; - - // Index documents - indexingService.indexJsonDocuments(COLLECTION_NAME, json); - - // Verify documents were indexed by searching for them - SearchResponse result = - searchService.search(COLLECTION_NAME, "id:test004", null, null, null, null, null); - - assertNotNull(result); - List> documents = result.documents(); - assertEquals(1, documents.size()); - - Map book = documents.getFirst(); - - // Handle ID field - Object idValue = book.get("id"); - if (idValue instanceof List) { - assertEquals("test004", ((List) idValue).getFirst()); - } else { - assertEquals("test004", idValue); - } - - // Handle name field - Object nameValue = book.get("name"); - if (nameValue instanceof List) { - assertEquals("Test Book 4", ((List) nameValue).getFirst()); - } else { - assertEquals("Test Book 4", nameValue); - } - - // Check that nested fields were flattened with underscore prefix - assertNotNull(book.get("details_publisher")); - Object publisherValue = book.get("details_publisher"); - if (publisherValue instanceof List) { - assertEquals("Test Publisher", ((List) publisherValue).getFirst()); - } else { - assertEquals("Test Publisher", publisherValue); - } - - assertNotNull(book.get("details_year")); - Object yearValue = book.get("details_year"); - if (yearValue instanceof List) { - assertEquals(2023, ((Number) ((List) yearValue).getFirst()).intValue()); - } else if (yearValue instanceof Number) { - assertEquals(2023, ((Number) yearValue).intValue()); - } else { - assertEquals("2023", yearValue.toString()); - } - } - - @Test - void testSanitizeFieldName() throws Exception { - - // Test JSON string with field names that need sanitizing - String json = - """ - [ - { - "id": "test005", - "invalid-field": "Value with hyphen", - "another.invalid": "Value with dot", - "UPPERCASE": "Value with uppercase", - "multiple__underscores": "Value with multiple underscores" - } - ] - """; - - // Index documents - indexingService.indexJsonDocuments(COLLECTION_NAME, json); - - // Verify documents were indexed with sanitized field names - SearchResponse result = - searchService.search(COLLECTION_NAME, "id:test005", null, null, null, null, null); - - assertNotNull(result); - List> documents = result.documents(); - assertEquals(1, documents.size()); - - Map doc = documents.getFirst(); - - // Check that field names were sanitized - assertNotNull(doc.get("invalid_field")); - Object invalidFieldValue = doc.get("invalid_field"); - if (invalidFieldValue instanceof List) { - assertEquals("Value with hyphen", ((List) invalidFieldValue).getFirst()); - } else { - assertEquals("Value with hyphen", invalidFieldValue); - } - - assertNotNull(doc.get("another_invalid")); - Object anotherInvalidValue = doc.get("another_invalid"); - if (anotherInvalidValue instanceof List) { - assertEquals("Value with dot", ((List) anotherInvalidValue).getFirst()); - } else { - assertEquals("Value with dot", anotherInvalidValue); - } - - // Should be lowercase - assertNotNull(doc.get("uppercase")); - Object uppercaseValue = doc.get("uppercase"); - if (uppercaseValue instanceof List) { - assertEquals("Value with uppercase", ((List) uppercaseValue).getFirst()); - } else { - assertEquals("Value with uppercase", uppercaseValue); - } - - // Multiple underscores should be collapsed - assertNotNull(doc.get("multiple_underscores")); - Object multipleUnderscoresValue = doc.get("multiple_underscores"); - if (multipleUnderscoresValue instanceof List) { - assertEquals( - "Value with multiple underscores", - ((List) multipleUnderscoresValue).getFirst()); - } else { - assertEquals("Value with multiple underscores", multipleUnderscoresValue); - } - } - - @Test - void testDeeplyNestedJsonStructures() throws Exception { - - // Test JSON string with deeply nested objects (3+ levels) - String json = - """ - [ - { - "id": "nested001", - "title": "Deeply nested document", - "metadata": { - "publication": { - "publisher": { - "name": "Deep Nest Publishing", - "location": { - "city": "Nestville", - "country": "Nestland", - "coordinates": { - "latitude": 42.123, - "longitude": -71.456 - } - } - }, - "year": 2023, - "edition": { - "number": 1, - "type": "First Edition", - "notes": { - "condition": "New", - "availability": "Limited" - } - } - }, - "classification": { - "primary": "Test", - "secondary": { - "category": "Nested", - "subcategory": "Deep" - } - } - } - } - ] - """; - - // Index documents - indexingService.indexJsonDocuments(COLLECTION_NAME, json); - - // Verify documents were indexed by searching for them - SearchResponse result = - searchService.search(COLLECTION_NAME, "id:nested001", null, null, null, null, null); - - assertNotNull(result); - List> documents = result.documents(); - assertEquals(1, documents.size()); - - Map doc = documents.getFirst(); - - // Check that deeply nested fields were flattened with underscore prefix - // Level 1 - assertNotNull(doc.get("metadata_publication_publisher_name")); - assertEquals( - "Deep Nest Publishing", getFieldValue(doc, "metadata_publication_publisher_name")); - - // Level 2 - assertNotNull(doc.get("metadata_publication_publisher_location_city")); - assertEquals( - "Nestville", getFieldValue(doc, "metadata_publication_publisher_location_city")); - - // Level 3 - assertNotNull(doc.get("metadata_publication_publisher_location_coordinates_latitude")); - assertEquals( - 42.123, - ((Number) - getFieldValue( - doc, - "metadata_publication_publisher_location_coordinates_latitude")) - .doubleValue(), - 0.001); - - // Check other branches of the nested structure - assertNotNull(doc.get("metadata_publication_edition_notes_condition")); - assertEquals("New", getFieldValue(doc, "metadata_publication_edition_notes_condition")); - - assertNotNull(doc.get("metadata_classification_secondary_subcategory")); - assertEquals("Deep", getFieldValue(doc, "metadata_classification_secondary_subcategory")); - } - - private Object getFieldValue(Map doc, String fieldName) { - Object value = doc.get(fieldName); - if (value instanceof List) { - return ((List) value).getFirst(); - } - return value; - } - - @Test - void testSpecialCharactersInFieldNames() throws Exception { - - // Test JSON string with field names containing various special characters - String json = - """ - [ - { - "id": "special_fields_001", - "field@with@at": "Value with @ symbols", - "field#with#hash": "Value with # symbols", - "field$with$dollar": "Value with $ symbols", - "field%with%percent": "Value with % symbols", - "field^with^caret": "Value with ^ symbols", - "field&with&ersand": "Value with & symbols", - "field*with*asterisk": "Value with * symbols", - "field(with)parentheses": "Value with parentheses", - "field[with]brackets": "Value with brackets", - "field{with}braces": "Value with braces", - "field+with+plus": "Value with + symbols", - "field=with=equals": "Value with = symbols", - "field:with:colon": "Value with : symbols", - "field;with;semicolon": "Value with ; symbols", - "field'with'quotes": "Value with ' symbols", - "field\\"with\\"doublequotes": "Value with \\" symbols", - "fieldanglebrackets": "Value with angle brackets", - "field,with,commas": "Value with , symbols", - "field?with?question": "Value with ? symbols", - "field/with/slashes": "Value with / symbols", - "field\\\\with\\\\backslashes": "Value with \\\\ symbols", - "field|with|pipes": "Value with | symbols", - "field`with`backticks": "Value with ` symbols", - "field~with~tildes": "Value with ~ symbols" - } - ] - """; - - // Index documents - indexingService.indexJsonDocuments(COLLECTION_NAME, json); - - // Verify documents were indexed by searching for them - SearchResponse result = - searchService.search( - COLLECTION_NAME, "id:special_fields_001", null, null, null, null, null); - - assertNotNull(result); - List> documents = result.documents(); - assertEquals(1, documents.size()); - - Map doc = documents.getFirst(); - - // Check that field names with special characters were sanitized - // All special characters should be replaced with underscores - assertNotNull(doc.get("field_with_at")); - assertEquals("Value with @ symbols", getFieldValue(doc, "field_with_at")); - - assertNotNull(doc.get("field_with_hash")); - assertEquals("Value with # symbols", getFieldValue(doc, "field_with_hash")); - - assertNotNull(doc.get("field_with_dollar")); - assertEquals("Value with $ symbols", getFieldValue(doc, "field_with_dollar")); - - assertNotNull(doc.get("field_with_percent")); - assertEquals("Value with % symbols", getFieldValue(doc, "field_with_percent")); - - assertNotNull(doc.get("field_with_caret")); - assertEquals("Value with ^ symbols", getFieldValue(doc, "field_with_caret")); - - assertNotNull(doc.get("field_with_ampersand")); - assertEquals("Value with & symbols", getFieldValue(doc, "field_with_ampersand")); - - assertNotNull(doc.get("field_with_asterisk")); - assertEquals("Value with * symbols", getFieldValue(doc, "field_with_asterisk")); - - assertNotNull(doc.get("field_with_parentheses")); - assertEquals("Value with parentheses", getFieldValue(doc, "field_with_parentheses")); - - assertNotNull(doc.get("field_with_brackets")); - assertEquals("Value with brackets", getFieldValue(doc, "field_with_brackets")); - - assertNotNull(doc.get("field_with_braces")); - assertEquals("Value with braces", getFieldValue(doc, "field_with_braces")); - } - - @Test - void testArraysOfObjects() throws Exception { - - // Test JSON string with arrays of objects - String json = - """ - [ - { - "id": "array_objects_001", - "title": "Document with arrays of objects", - "authors": [ - { - "name": "Author One", - "email": "author1@example.com", - "affiliation": "University A" - }, - { - "name": "Author Two", - "email": "author2@example.com", - "affiliation": "University B" - } - ], - "reviews": [ - { - "reviewer": "Reviewer A", - "rating": 4, - "comments": "Good document" - }, - { - "reviewer": "Reviewer B", - "rating": 5, - "comments": "Excellent document" - }, - { - "reviewer": "Reviewer C", - "rating": 3, - "comments": "Average document" - } - ], - "keywords": ["arrays", "objects", "testing"] - } - ] - """; - - // Index documents - indexingService.indexJsonDocuments(COLLECTION_NAME, json); - - // Verify documents were indexed by searching for them - SearchResponse result = - searchService.search( - COLLECTION_NAME, "id:array_objects_001", null, null, null, null, null); - - assertNotNull(result); - List> documents = result.documents(); - assertEquals(1, documents.size()); - - Map doc = documents.getFirst(); - - // Check that the document was indexed correctly - assertEquals("array_objects_001", getFieldValue(doc, "id")); - assertEquals("Document with arrays of objects", getFieldValue(doc, "title")); - - // Check that the arrays of primitive values were indexed correctly - Object keywordsObj = doc.get("keywords"); - if (keywordsObj instanceof List) { - List keywords = (List) keywordsObj; - assertEquals(3, keywords.size()); - assertTrue(keywords.contains("arrays")); - assertTrue(keywords.contains("objects")); - assertTrue(keywords.contains("testing")); - } - - // For arrays of objects, the IndexingService should flatten them with field names - // that include the array name and the object field name - // We can't directly access the array elements, but we can check if the flattened fields - // exist - - // Check for flattened author fields - // Note: The current implementation in IndexingService.java doesn't handle arrays of objects - // in a way that preserves the array structure. It skips object items in arrays (line - // 68-70). - // This test is checking the current behavior, which may need improvement in the future. - - // Check for flattened review fields - // Same note as above applies here - } - - @Test - void testNonArrayJsonInput() throws Exception { - // Test JSON string that is not an array but a single object - String json = - """ - { - "id": "single_object_001", - "title": "Single Object Document", - "author": "Test Author", - "year": 2023 - } - """; - - // Create documents - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromJson(json); - - // Verify no documents were created since input is not an array - assertNotNull(documents); - assertEquals(0, documents.size()); - } - - @Test - void testConvertJsonValueTypes() throws Exception { - // Test JSON with different value types - String json = - """ - [ - { - "id": "value_types_001", - "boolean_value": true, - "int_value": 42, - "double_value": 3.14159, - "long_value": 9223372036854775807, - "text_value": "This is a text value" - } - ] - """; - - // Create documents - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromJson(json); - - // Verify documents were created correctly - assertNotNull(documents); - assertEquals(1, documents.size()); - - SolrInputDocument doc = documents.getFirst(); - assertEquals("value_types_001", doc.getFieldValue("id")); - - // Verify each value type was converted correctly - assertEquals(true, doc.getFieldValue("boolean_value")); - assertEquals(42, doc.getFieldValue("int_value")); - assertEquals(3.14159, doc.getFieldValue("double_value")); - assertEquals(9223372036854775807L, doc.getFieldValue("long_value")); - assertEquals("This is a text value", doc.getFieldValue("text_value")); - } - - @Test - void testDirectSanitizeFieldName() throws Exception { - // Test sanitizing field names directly - // Create a document with field names that need sanitizing - String json = - """ - [ - { - "id": "field_names_001", - "field-with-hyphens": "Value 1", - "field.with.dots": "Value 2", - "field with spaces": "Value 3", - "UPPERCASE_FIELD": "Value 4", - "__leading_underscores__": "Value 5", - "trailing_underscores___": "Value 6", - "multiple___underscores": "Value 7" - } - ] - """; - - // Create documents - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromJson(json); - - // Verify documents were created correctly - assertNotNull(documents); - assertEquals(1, documents.size()); - - SolrInputDocument doc = documents.getFirst(); - - // Verify field names were sanitized correctly - assertEquals("field_names_001", doc.getFieldValue("id")); - assertEquals("Value 1", doc.getFieldValue("field_with_hyphens")); - assertEquals("Value 2", doc.getFieldValue("field_with_dots")); - assertEquals("Value 3", doc.getFieldValue("field_with_spaces")); - assertEquals("Value 4", doc.getFieldValue("uppercase_field")); - assertEquals("Value 5", doc.getFieldValue("leading_underscores")); - assertEquals("Value 6", doc.getFieldValue("trailing_underscores")); - assertEquals("Value 7", doc.getFieldValue("multiple_underscores")); - } + private static boolean initialized = false; + + private static final String COLLECTION_NAME = "indexing_test_" + System.currentTimeMillis(); + @Autowired + private SolrContainer solrContainer; + @Autowired + private IndexingDocumentCreator indexingDocumentCreator; + @Autowired + private IndexingService indexingService; + @Autowired + private SearchService searchService; + @Autowired + private SolrClient solrClient; + + @BeforeEach + void setUp() throws Exception { + + // Create processor instances and wire them manually since this is not a Spring + // Boot test + XmlDocumentCreator xmlDocumentCreator = new XmlDocumentCreator(); + CsvDocumentCreator csvDocumentCreator = new CsvDocumentCreator(); + JsonDocumentCreator jsonDocumentCreator = new JsonDocumentCreator(); + + indexingDocumentCreator = new IndexingDocumentCreator(xmlDocumentCreator, csvDocumentCreator, + jsonDocumentCreator); + + indexingService = new IndexingService(solrClient, indexingDocumentCreator); + searchService = new SearchService(solrClient); + + if (!initialized) { + // Create collection + CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection(COLLECTION_NAME, + "_default", 1, 1); + createRequest.process(solrClient); + initialized = true; + } + } + + @Test + void testCreateSchemalessDocumentsFromJson() throws Exception { + // Test JSON string + String json = """ + [ + { + "id": "test001", + "cat": ["book"], + "name": ["Test Book 1"], + "price": [9.99], + "inStock": [true], + "author": ["Test Author"], + "series_t": "Test Series", + "sequence_i": 1, + "genre_s": "test" + } + ] + """; + + // Create documents + List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + + // Verify documents were created correctly + assertNotNull(documents); + assertEquals(1, documents.size()); + + SolrInputDocument doc = documents.getFirst(); + assertEquals("test001", doc.getFieldValue("id")); + + // Check field values - they might be stored directly or as collections + Object nameValue = doc.getFieldValue("name"); + if (nameValue instanceof List) { + assertEquals("Test Book 1", ((List) nameValue).getFirst()); + } else { + assertEquals("Test Book 1", nameValue); + } + + Object priceValue = doc.getFieldValue("price"); + if (priceValue instanceof List) { + assertEquals(9.99, ((List) priceValue).getFirst()); + } else { + assertEquals(9.99, priceValue); + } + + Object inStockValue = doc.getFieldValue("inStock"); + // Check if inStock field exists + if (inStockValue != null) { + if (inStockValue instanceof List) { + assertEquals(true, ((List) inStockValue).getFirst()); + } else { + assertEquals(true, inStockValue); + } + } else { + // If inStock is not present in the document, we'll skip this assertion + // Removed debug print statement + } + + Object authorValue = doc.getFieldValue("author"); + if (authorValue instanceof List) { + assertEquals("Test Author", ((List) authorValue).getFirst()); + } else { + assertEquals("Test Author", authorValue); + } + + assertEquals("Test Series", doc.getFieldValue("series_t")); + assertEquals(1, doc.getFieldValue("sequence_i")); + assertEquals("test", doc.getFieldValue("genre_s")); + } + + @Test + void testIndexJsonDocuments() throws Exception { + + // Test JSON string with multiple documents + String json = """ + [ + { + "id": "test002", + "cat": ["book"], + "name": ["Test Book 2"], + "price": [19.99], + "inStock": [true], + "author": ["Test Author 2"], + "genre_s": "scifi" + }, + { + "id": "test003", + "cat": ["book"], + "name": ["Test Book 3"], + "price": [29.99], + "inStock": [false], + "author": ["Test Author 3"], + "genre_s": "fantasy" + } + ] + """; + + // Index documents + indexingService.indexJsonDocuments(COLLECTION_NAME, json); + + // Verify documents were indexed by searching for them + SearchResponse result = searchService.search(COLLECTION_NAME, "id:test002 OR id:test003", null, null, null, + null, null); + + assertNotNull(result); + List> documents = result.documents(); + assertEquals(2, documents.size()); + + // Verify specific document fields + boolean foundBook2 = false; + boolean foundBook3 = false; + + for (Map book : documents) { + // Get ID and handle both String and List cases + Object idValue = book.get("id"); + String id; + if (idValue instanceof List) { + id = (String) ((List) idValue).getFirst(); + } else { + id = (String) idValue; + } + + if (id.equals("test002")) { + foundBook2 = true; + + // Handle name field + Object nameValue = book.get("name"); + if (nameValue instanceof List) { + assertEquals("Test Book 2", ((List) nameValue).getFirst()); + } else { + assertEquals("Test Book 2", nameValue); + } + + // Handle author field + Object authorValue = book.get("author"); + if (authorValue instanceof List) { + assertEquals("Test Author 2", ((List) authorValue).getFirst()); + } else { + assertEquals("Test Author 2", authorValue); + } + + // Handle genre field + Object genreValue = book.get("genre_s"); + if (genreValue instanceof List) { + assertEquals("scifi", ((List) genreValue).getFirst()); + } else { + assertEquals("scifi", genreValue); + } + } else if (id.equals("test003")) { + foundBook3 = true; + + // Handle name field + Object nameValue = book.get("name"); + if (nameValue instanceof List) { + assertEquals("Test Book 3", ((List) nameValue).getFirst()); + } else { + assertEquals("Test Book 3", nameValue); + } + + // Handle author field + Object authorValue = book.get("author"); + if (authorValue instanceof List) { + assertEquals("Test Author 3", ((List) authorValue).getFirst()); + } else { + assertEquals("Test Author 3", authorValue); + } + + // Handle genre field + Object genreValue = book.get("genre_s"); + if (genreValue instanceof List) { + assertEquals("fantasy", ((List) genreValue).getFirst()); + } else { + assertEquals("fantasy", genreValue); + } + } + } + + assertTrue(foundBook2, "Book 2 should be found in search results"); + assertTrue(foundBook3, "Book 3 should be found in search results"); + } + + @Test + void testIndexJsonDocumentsWithNestedObjects() throws Exception { + + // Test JSON string with nested objects + String json = """ + [ + { + "id": "test004", + "cat": ["book"], + "name": ["Test Book 4"], + "price": [39.99], + "details": { + "publisher": "Test Publisher", + "year": 2023, + "edition": 1 + }, + "author": ["Test Author 4"] + } + ] + """; + + // Index documents + indexingService.indexJsonDocuments(COLLECTION_NAME, json); + + // Verify documents were indexed by searching for them + SearchResponse result = searchService.search(COLLECTION_NAME, "id:test004", null, null, null, null, null); + + assertNotNull(result); + List> documents = result.documents(); + assertEquals(1, documents.size()); + + Map book = documents.getFirst(); + + // Handle ID field + Object idValue = book.get("id"); + if (idValue instanceof List) { + assertEquals("test004", ((List) idValue).getFirst()); + } else { + assertEquals("test004", idValue); + } + + // Handle name field + Object nameValue = book.get("name"); + if (nameValue instanceof List) { + assertEquals("Test Book 4", ((List) nameValue).getFirst()); + } else { + assertEquals("Test Book 4", nameValue); + } + + // Check that nested fields were flattened with underscore prefix + assertNotNull(book.get("details_publisher")); + Object publisherValue = book.get("details_publisher"); + if (publisherValue instanceof List) { + assertEquals("Test Publisher", ((List) publisherValue).getFirst()); + } else { + assertEquals("Test Publisher", publisherValue); + } + + assertNotNull(book.get("details_year")); + Object yearValue = book.get("details_year"); + if (yearValue instanceof List) { + assertEquals(2023, ((Number) ((List) yearValue).getFirst()).intValue()); + } else if (yearValue instanceof Number) { + assertEquals(2023, ((Number) yearValue).intValue()); + } else { + assertEquals("2023", yearValue.toString()); + } + } + + @Test + void testSanitizeFieldName() throws Exception { + + // Test JSON string with field names that need sanitizing + String json = """ + [ + { + "id": "test005", + "invalid-field": "Value with hyphen", + "another.invalid": "Value with dot", + "UPPERCASE": "Value with uppercase", + "multiple__underscores": "Value with multiple underscores" + } + ] + """; + + // Index documents + indexingService.indexJsonDocuments(COLLECTION_NAME, json); + + // Verify documents were indexed with sanitized field names + SearchResponse result = searchService.search(COLLECTION_NAME, "id:test005", null, null, null, null, null); + + assertNotNull(result); + List> documents = result.documents(); + assertEquals(1, documents.size()); + + Map doc = documents.getFirst(); + + // Check that field names were sanitized + assertNotNull(doc.get("invalid_field")); + Object invalidFieldValue = doc.get("invalid_field"); + if (invalidFieldValue instanceof List) { + assertEquals("Value with hyphen", ((List) invalidFieldValue).getFirst()); + } else { + assertEquals("Value with hyphen", invalidFieldValue); + } + + assertNotNull(doc.get("another_invalid")); + Object anotherInvalidValue = doc.get("another_invalid"); + if (anotherInvalidValue instanceof List) { + assertEquals("Value with dot", ((List) anotherInvalidValue).getFirst()); + } else { + assertEquals("Value with dot", anotherInvalidValue); + } + + // Should be lowercase + assertNotNull(doc.get("uppercase")); + Object uppercaseValue = doc.get("uppercase"); + if (uppercaseValue instanceof List) { + assertEquals("Value with uppercase", ((List) uppercaseValue).getFirst()); + } else { + assertEquals("Value with uppercase", uppercaseValue); + } + + // Multiple underscores should be collapsed + assertNotNull(doc.get("multiple_underscores")); + Object multipleUnderscoresValue = doc.get("multiple_underscores"); + if (multipleUnderscoresValue instanceof List) { + assertEquals("Value with multiple underscores", ((List) multipleUnderscoresValue).getFirst()); + } else { + assertEquals("Value with multiple underscores", multipleUnderscoresValue); + } + } + + @Test + void testDeeplyNestedJsonStructures() throws Exception { + + // Test JSON string with deeply nested objects (3+ levels) + String json = """ + [ + { + "id": "nested001", + "title": "Deeply nested document", + "metadata": { + "publication": { + "publisher": { + "name": "Deep Nest Publishing", + "location": { + "city": "Nestville", + "country": "Nestland", + "coordinates": { + "latitude": 42.123, + "longitude": -71.456 + } + } + }, + "year": 2023, + "edition": { + "number": 1, + "type": "First Edition", + "notes": { + "condition": "New", + "availability": "Limited" + } + } + }, + "classification": { + "primary": "Test", + "secondary": { + "category": "Nested", + "subcategory": "Deep" + } + } + } + } + ] + """; + + // Index documents + indexingService.indexJsonDocuments(COLLECTION_NAME, json); + + // Verify documents were indexed by searching for them + SearchResponse result = searchService.search(COLLECTION_NAME, "id:nested001", null, null, null, null, null); + + assertNotNull(result); + List> documents = result.documents(); + assertEquals(1, documents.size()); + + Map doc = documents.getFirst(); + + // Check that deeply nested fields were flattened with underscore prefix + // Level 1 + assertNotNull(doc.get("metadata_publication_publisher_name")); + assertEquals("Deep Nest Publishing", getFieldValue(doc, "metadata_publication_publisher_name")); + + // Level 2 + assertNotNull(doc.get("metadata_publication_publisher_location_city")); + assertEquals("Nestville", getFieldValue(doc, "metadata_publication_publisher_location_city")); + + // Level 3 + assertNotNull(doc.get("metadata_publication_publisher_location_coordinates_latitude")); + assertEquals(42.123, + ((Number) getFieldValue(doc, "metadata_publication_publisher_location_coordinates_latitude")) + .doubleValue(), + 0.001); + + // Check other branches of the nested structure + assertNotNull(doc.get("metadata_publication_edition_notes_condition")); + assertEquals("New", getFieldValue(doc, "metadata_publication_edition_notes_condition")); + + assertNotNull(doc.get("metadata_classification_secondary_subcategory")); + assertEquals("Deep", getFieldValue(doc, "metadata_classification_secondary_subcategory")); + } + + private Object getFieldValue(Map doc, String fieldName) { + Object value = doc.get(fieldName); + if (value instanceof List) { + return ((List) value).getFirst(); + } + return value; + } + + @Test + void testSpecialCharactersInFieldNames() throws Exception { + + // Test JSON string with field names containing various special characters + String json = """ + [ + { + "id": "special_fields_001", + "field@with@at": "Value with @ symbols", + "field#with#hash": "Value with # symbols", + "field$with$dollar": "Value with $ symbols", + "field%with%percent": "Value with % symbols", + "field^with^caret": "Value with ^ symbols", + "field&with&ersand": "Value with & symbols", + "field*with*asterisk": "Value with * symbols", + "field(with)parentheses": "Value with parentheses", + "field[with]brackets": "Value with brackets", + "field{with}braces": "Value with braces", + "field+with+plus": "Value with + symbols", + "field=with=equals": "Value with = symbols", + "field:with:colon": "Value with : symbols", + "field;with;semicolon": "Value with ; symbols", + "field'with'quotes": "Value with ' symbols", + "field\\"with\\"doublequotes": "Value with \\" symbols", + "fieldanglebrackets": "Value with angle brackets", + "field,with,commas": "Value with , symbols", + "field?with?question": "Value with ? symbols", + "field/with/slashes": "Value with / symbols", + "field\\\\with\\\\backslashes": "Value with \\\\ symbols", + "field|with|pipes": "Value with | symbols", + "field`with`backticks": "Value with ` symbols", + "field~with~tildes": "Value with ~ symbols" + } + ] + """; + + // Index documents + indexingService.indexJsonDocuments(COLLECTION_NAME, json); + + // Verify documents were indexed by searching for them + SearchResponse result = searchService.search(COLLECTION_NAME, "id:special_fields_001", null, null, null, null, + null); + + assertNotNull(result); + List> documents = result.documents(); + assertEquals(1, documents.size()); + + Map doc = documents.getFirst(); + + // Check that field names with special characters were sanitized + // All special characters should be replaced with underscores + assertNotNull(doc.get("field_with_at")); + assertEquals("Value with @ symbols", getFieldValue(doc, "field_with_at")); + + assertNotNull(doc.get("field_with_hash")); + assertEquals("Value with # symbols", getFieldValue(doc, "field_with_hash")); + + assertNotNull(doc.get("field_with_dollar")); + assertEquals("Value with $ symbols", getFieldValue(doc, "field_with_dollar")); + + assertNotNull(doc.get("field_with_percent")); + assertEquals("Value with % symbols", getFieldValue(doc, "field_with_percent")); + + assertNotNull(doc.get("field_with_caret")); + assertEquals("Value with ^ symbols", getFieldValue(doc, "field_with_caret")); + + assertNotNull(doc.get("field_with_ampersand")); + assertEquals("Value with & symbols", getFieldValue(doc, "field_with_ampersand")); + + assertNotNull(doc.get("field_with_asterisk")); + assertEquals("Value with * symbols", getFieldValue(doc, "field_with_asterisk")); + + assertNotNull(doc.get("field_with_parentheses")); + assertEquals("Value with parentheses", getFieldValue(doc, "field_with_parentheses")); + + assertNotNull(doc.get("field_with_brackets")); + assertEquals("Value with brackets", getFieldValue(doc, "field_with_brackets")); + + assertNotNull(doc.get("field_with_braces")); + assertEquals("Value with braces", getFieldValue(doc, "field_with_braces")); + } + + @Test + void testArraysOfObjects() throws Exception { + + // Test JSON string with arrays of objects + String json = """ + [ + { + "id": "array_objects_001", + "title": "Document with arrays of objects", + "authors": [ + { + "name": "Author One", + "email": "author1@example.com", + "affiliation": "University A" + }, + { + "name": "Author Two", + "email": "author2@example.com", + "affiliation": "University B" + } + ], + "reviews": [ + { + "reviewer": "Reviewer A", + "rating": 4, + "comments": "Good document" + }, + { + "reviewer": "Reviewer B", + "rating": 5, + "comments": "Excellent document" + }, + { + "reviewer": "Reviewer C", + "rating": 3, + "comments": "Average document" + } + ], + "keywords": ["arrays", "objects", "testing"] + } + ] + """; + + // Index documents + indexingService.indexJsonDocuments(COLLECTION_NAME, json); + + // Verify documents were indexed by searching for them + SearchResponse result = searchService.search(COLLECTION_NAME, "id:array_objects_001", null, null, null, null, + null); + + assertNotNull(result); + List> documents = result.documents(); + assertEquals(1, documents.size()); + + Map doc = documents.getFirst(); + + // Check that the document was indexed correctly + assertEquals("array_objects_001", getFieldValue(doc, "id")); + assertEquals("Document with arrays of objects", getFieldValue(doc, "title")); + + // Check that the arrays of primitive values were indexed correctly + Object keywordsObj = doc.get("keywords"); + if (keywordsObj instanceof List) { + List keywords = (List) keywordsObj; + assertEquals(3, keywords.size()); + assertTrue(keywords.contains("arrays")); + assertTrue(keywords.contains("objects")); + assertTrue(keywords.contains("testing")); + } + + // For arrays of objects, the IndexingService should flatten them with field + // names + // that include the array name and the object field name + // We can't directly access the array elements, but we can check if the + // flattened fields + // exist + + // Check for flattened author fields + // Note: The current implementation in IndexingService.java doesn't handle + // arrays of objects + // in a way that preserves the array structure. It skips object items in arrays + // (line + // 68-70). + // This test is checking the current behavior, which may need improvement in the + // future. + + // Check for flattened review fields + // Same note as above applies here + } + + @Test + void testNonArrayJsonInput() throws Exception { + // Test JSON string that is not an array but a single object + String json = """ + { + "id": "single_object_001", + "title": "Single Object Document", + "author": "Test Author", + "year": 2023 + } + """; + + // Create documents + List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + + // Verify no documents were created since input is not an array + assertNotNull(documents); + assertEquals(0, documents.size()); + } + + @Test + void testConvertJsonValueTypes() throws Exception { + // Test JSON with different value types + String json = """ + [ + { + "id": "value_types_001", + "boolean_value": true, + "int_value": 42, + "double_value": 3.14159, + "long_value": 9223372036854775807, + "text_value": "This is a text value" + } + ] + """; + + // Create documents + List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + + // Verify documents were created correctly + assertNotNull(documents); + assertEquals(1, documents.size()); + + SolrInputDocument doc = documents.getFirst(); + assertEquals("value_types_001", doc.getFieldValue("id")); + + // Verify each value type was converted correctly + assertEquals(true, doc.getFieldValue("boolean_value")); + assertEquals(42, doc.getFieldValue("int_value")); + assertEquals(3.14159, doc.getFieldValue("double_value")); + assertEquals(9223372036854775807L, doc.getFieldValue("long_value")); + assertEquals("This is a text value", doc.getFieldValue("text_value")); + } + + @Test + void testDirectSanitizeFieldName() throws Exception { + // Test sanitizing field names directly + // Create a document with field names that need sanitizing + String json = """ + [ + { + "id": "field_names_001", + "field-with-hyphens": "Value 1", + "field.with.dots": "Value 2", + "field with spaces": "Value 3", + "UPPERCASE_FIELD": "Value 4", + "__leading_underscores__": "Value 5", + "trailing_underscores___": "Value 6", + "multiple___underscores": "Value 7" + } + ] + """; + + // Create documents + List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + + // Verify documents were created correctly + assertNotNull(documents); + assertEquals(1, documents.size()); + + SolrInputDocument doc = documents.getFirst(); + + // Verify field names were sanitized correctly + assertEquals("field_names_001", doc.getFieldValue("id")); + assertEquals("Value 1", doc.getFieldValue("field_with_hyphens")); + assertEquals("Value 2", doc.getFieldValue("field_with_dots")); + assertEquals("Value 3", doc.getFieldValue("field_with_spaces")); + assertEquals("Value 4", doc.getFieldValue("uppercase_field")); + assertEquals("Value 5", doc.getFieldValue("leading_underscores")); + assertEquals("Value 6", doc.getFieldValue("trailing_underscores")); + assertEquals("Value 7", doc.getFieldValue("multiple_underscores")); + } } @Nested @ExtendWith(MockitoExtension.class) class UnitTests { - @Mock private SolrClient solrClient; - - @Mock private IndexingDocumentCreator indexingDocumentCreator; - - private IndexingService indexingService; - - @BeforeEach - void setUp() { - indexingService = new IndexingService(solrClient, indexingDocumentCreator); - } - - @Test - void constructor_ShouldInitializeWithDependencies() { - assertNotNull(indexingService); - } - - @Test - void indexJsonDocuments_WithValidJson_ShouldIndexDocuments() throws Exception { - String json = "[{\"id\":\"1\",\"title\":\"Test\"}]"; - List mockDocs = createMockDocuments(1); - when(indexingDocumentCreator.createSchemalessDocumentsFromJson(json)).thenReturn(mockDocs); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - indexingService.indexJsonDocuments("test_collection", json); - - verify(indexingDocumentCreator).createSchemalessDocumentsFromJson(json); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexJsonDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() - throws Exception { - String invalidJson = "not valid json"; - when(indexingDocumentCreator.createSchemalessDocumentsFromJson(invalidJson)) - .thenThrow( - new org.apache.solr.mcp.server.indexing.documentcreator - .DocumentProcessingException("Invalid JSON")); - - assertThrows( - org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException - .class, - () -> { - indexingService.indexJsonDocuments("test_collection", invalidJson); - }); - verify(solrClient, never()).add(anyString(), any(Collection.class)); - verify(solrClient, never()).commit(anyString()); - } - - @Test - void indexCsvDocuments_WithValidCsv_ShouldIndexDocuments() throws Exception { - String csv = "id,title\n1,Test\n2,Test2"; - List mockDocs = createMockDocuments(2); - when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv)).thenReturn(mockDocs); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - indexingService.indexCsvDocuments("test_collection", csv); - - verify(indexingDocumentCreator).createSchemalessDocumentsFromCsv(csv); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexCsvDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() - throws Exception { - String invalidCsv = "malformed csv data"; - when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(invalidCsv)) - .thenThrow( - new org.apache.solr.mcp.server.indexing.documentcreator - .DocumentProcessingException("Invalid CSV")); - - assertThrows( - org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException - .class, - () -> { - indexingService.indexCsvDocuments("test_collection", invalidCsv); - }); - verify(solrClient, never()).add(anyString(), any(Collection.class)); - verify(solrClient, never()).commit(anyString()); - } - - @Test - void indexXmlDocuments_WithValidXml_ShouldIndexDocuments() throws Exception { - String xml = "1Test"; - List mockDocs = createMockDocuments(1); - when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)).thenReturn(mockDocs); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - indexingService.indexXmlDocuments("test_collection", xml); - - verify(indexingDocumentCreator).createSchemalessDocumentsFromXml(xml); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexXmlDocuments_WhenParserConfigurationFails_ShouldPropagateException() - throws Exception { - String xml = "xml"; - when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)) - .thenThrow( - new org.apache.solr.mcp.server.indexing.documentcreator - .DocumentProcessingException("Parser error")); - - assertThrows( - org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException - .class, - () -> { - indexingService.indexXmlDocuments("test_collection", xml); - }); - verify(solrClient, never()).add(anyString(), any(Collection.class)); - verify(solrClient, never()).commit(anyString()); - } - - @Test - void indexXmlDocuments_WhenSaxExceptionOccurs_ShouldPropagateException() throws Exception { - String xml = ""; - when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)) - .thenThrow( - new org.apache.solr.mcp.server.indexing.documentcreator - .DocumentProcessingException("SAX parsing error")); - - assertThrows( - org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException - .class, - () -> { - indexingService.indexXmlDocuments("test_collection", xml); - }); - verify(solrClient, never()).add(anyString(), any(Collection.class)); - verify(solrClient, never()).commit(anyString()); - } - - @Test - void indexDocuments_WithSmallBatch_ShouldIndexSuccessfully() throws Exception { - List docs = createMockDocuments(5); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", docs); - - assertEquals(5, result); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexDocuments_WithLargeBatch_ShouldProcessInBatches() throws Exception { - List docs = createMockDocuments(2500); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit(eq("test_collection"))).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", docs); - - assertEquals(2500, result); - verify(solrClient, times(3)).add(eq("test_collection"), any(Collection.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexDocuments_WhenBatchFails_ShouldRetryIndividually() throws Exception { - List docs = createMockDocuments(3); - - when(solrClient.add(eq("test_collection"), any(List.class))) - .thenThrow(new SolrServerException("Batch error")); - - when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", docs); - - assertEquals(3, result); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient, times(3)).add(eq("test_collection"), any(SolrInputDocument.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexDocuments_WhenSomeIndividualDocumentsFail_ShouldIndexSuccessfulOnes() - throws Exception { - List docs = createMockDocuments(3); - - when(solrClient.add(eq("test_collection"), any(List.class))) - .thenThrow(new SolrServerException("Batch error")); - - when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))) - .thenReturn(null) - .thenThrow(new SolrServerException("Document error")) - .thenReturn(null); - - when(solrClient.commit("test_collection")).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", docs); - - assertEquals(2, result); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient, times(3)).add(eq("test_collection"), any(SolrInputDocument.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexDocuments_WithEmptyList_ShouldStillCommit() throws Exception { - List emptyDocs = new ArrayList<>(); - when(solrClient.commit("test_collection")).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", emptyDocs); - - assertEquals(0, result); - verify(solrClient, never()).add(anyString(), any(List.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexDocuments_WhenCommitFails_ShouldPropagateException() throws Exception { - List docs = createMockDocuments(2); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenThrow(new IOException("Commit failed")); - - assertThrows( - IOException.class, - () -> { - indexingService.indexDocuments("test_collection", docs); - }); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexDocuments_ShouldBatchCorrectly() throws Exception { - List docs = createMockDocuments(1000); - when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", docs); - - assertEquals(1000, result); - - ArgumentCaptor> captor = - ArgumentCaptor.forClass(Collection.class); - verify(solrClient).add(eq("test_collection"), captor.capture()); - assertEquals(1000, captor.getValue().size()); - verify(solrClient).commit("test_collection"); - } - - @Test - void indexJsonDocuments_WhenSolrClientThrowsException_ShouldPropagateException() - throws Exception { - String json = "[{\"id\":\"1\"}]"; - List mockDocs = createMockDocuments(1); - when(indexingDocumentCreator.createSchemalessDocumentsFromJson(json)).thenReturn(mockDocs); - when(solrClient.add(eq("test_collection"), any(List.class))) - .thenThrow(new SolrServerException("Solr connection error")); - when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))) - .thenThrow(new SolrServerException("Solr connection error")); - when(solrClient.commit("test_collection")).thenReturn(null); - - indexingService.indexJsonDocuments("test_collection", json); - - verify(solrClient).add(eq("test_collection"), any(List.class)); - verify(solrClient).add(eq("test_collection"), any(SolrInputDocument.class)); - } - - @Test - void indexCsvDocuments_WhenSolrClientThrowsIOException_ShouldPropagateException() - throws Exception { - String csv = "id,title\n1,Test"; - List mockDocs = createMockDocuments(1); - when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv)).thenReturn(mockDocs); - when(solrClient.add(eq("test_collection"), any(List.class))) - .thenThrow(new IOException("Network error")); - when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))) - .thenThrow(new IOException("Network error")); - when(solrClient.commit("test_collection")).thenReturn(null); - - indexingService.indexCsvDocuments("test_collection", csv); - - verify(solrClient).add(eq("test_collection"), any(List.class)); - verify(solrClient).add(eq("test_collection"), any(SolrInputDocument.class)); - } - - @Test - void indexDocuments_WithRuntimeException_ShouldRetryIndividually() throws Exception { - List docs = createMockDocuments(2); - - when(solrClient.add(eq("test_collection"), any(List.class))) - .thenThrow(new RuntimeException("Unexpected error")); - - when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))).thenReturn(null); - when(solrClient.commit("test_collection")).thenReturn(null); - - int result = indexingService.indexDocuments("test_collection", docs); - - assertEquals(2, result); - verify(solrClient).add(eq("test_collection"), any(Collection.class)); - verify(solrClient, times(2)).add(eq("test_collection"), any(SolrInputDocument.class)); - verify(solrClient).commit("test_collection"); - } - - private List createMockDocuments(int count) { - List docs = new ArrayList<>(); - for (int i = 0; i < count; i++) { - SolrInputDocument doc = new SolrInputDocument(); - doc.addField("id", "doc" + i); - doc.addField("title", "Document " + i); - docs.add(doc); - } - return docs; - } + @Mock + private SolrClient solrClient; + + @Mock + private IndexingDocumentCreator indexingDocumentCreator; + + private IndexingService indexingService; + + @BeforeEach + void setUp() { + indexingService = new IndexingService(solrClient, indexingDocumentCreator); + } + + @Test + void constructor_ShouldInitializeWithDependencies() { + assertNotNull(indexingService); + } + + @Test + void indexJsonDocuments_WithValidJson_ShouldIndexDocuments() throws Exception { + String json = "[{\"id\":\"1\",\"title\":\"Test\"}]"; + List mockDocs = createMockDocuments(1); + when(indexingDocumentCreator.createSchemalessDocumentsFromJson(json)).thenReturn(mockDocs); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + indexingService.indexJsonDocuments("test_collection", json); + + verify(indexingDocumentCreator).createSchemalessDocumentsFromJson(json); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexJsonDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() throws Exception { + String invalidJson = "not valid json"; + when(indexingDocumentCreator.createSchemalessDocumentsFromJson(invalidJson)).thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Invalid JSON")); + + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { + indexingService.indexJsonDocuments("test_collection", invalidJson); + }); + verify(solrClient, never()).add(anyString(), any(Collection.class)); + verify(solrClient, never()).commit(anyString()); + } + + @Test + void indexCsvDocuments_WithValidCsv_ShouldIndexDocuments() throws Exception { + String csv = "id,title\n1,Test\n2,Test2"; + List mockDocs = createMockDocuments(2); + when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv)).thenReturn(mockDocs); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + indexingService.indexCsvDocuments("test_collection", csv); + + verify(indexingDocumentCreator).createSchemalessDocumentsFromCsv(csv); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexCsvDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() throws Exception { + String invalidCsv = "malformed csv data"; + when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(invalidCsv)).thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Invalid CSV")); + + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { + indexingService.indexCsvDocuments("test_collection", invalidCsv); + }); + verify(solrClient, never()).add(anyString(), any(Collection.class)); + verify(solrClient, never()).commit(anyString()); + } + + @Test + void indexXmlDocuments_WithValidXml_ShouldIndexDocuments() throws Exception { + String xml = "1Test"; + List mockDocs = createMockDocuments(1); + when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)).thenReturn(mockDocs); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + indexingService.indexXmlDocuments("test_collection", xml); + + verify(indexingDocumentCreator).createSchemalessDocumentsFromXml(xml); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexXmlDocuments_WhenParserConfigurationFails_ShouldPropagateException() throws Exception { + String xml = "xml"; + when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)).thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Parser error")); + + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { + indexingService.indexXmlDocuments("test_collection", xml); + }); + verify(solrClient, never()).add(anyString(), any(Collection.class)); + verify(solrClient, never()).commit(anyString()); + } + + @Test + void indexXmlDocuments_WhenSaxExceptionOccurs_ShouldPropagateException() throws Exception { + String xml = ""; + when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)) + .thenThrow(new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException( + "SAX parsing error")); + + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { + indexingService.indexXmlDocuments("test_collection", xml); + }); + verify(solrClient, never()).add(anyString(), any(Collection.class)); + verify(solrClient, never()).commit(anyString()); + } + + @Test + void indexDocuments_WithSmallBatch_ShouldIndexSuccessfully() throws Exception { + List docs = createMockDocuments(5); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", docs); + + assertEquals(5, result); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexDocuments_WithLargeBatch_ShouldProcessInBatches() throws Exception { + List docs = createMockDocuments(2500); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit(eq("test_collection"))).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", docs); + + assertEquals(2500, result); + verify(solrClient, times(3)).add(eq("test_collection"), any(Collection.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexDocuments_WhenBatchFails_ShouldRetryIndividually() throws Exception { + List docs = createMockDocuments(3); + + when(solrClient.add(eq("test_collection"), any(List.class))).thenThrow(new SolrServerException("Batch error")); + + when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", docs); + + assertEquals(3, result); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient, times(3)).add(eq("test_collection"), any(SolrInputDocument.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexDocuments_WhenSomeIndividualDocumentsFail_ShouldIndexSuccessfulOnes() throws Exception { + List docs = createMockDocuments(3); + + when(solrClient.add(eq("test_collection"), any(List.class))).thenThrow(new SolrServerException("Batch error")); + + when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))).thenReturn(null) + .thenThrow(new SolrServerException("Document error")).thenReturn(null); + + when(solrClient.commit("test_collection")).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", docs); + + assertEquals(2, result); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient, times(3)).add(eq("test_collection"), any(SolrInputDocument.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexDocuments_WithEmptyList_ShouldStillCommit() throws Exception { + List emptyDocs = new ArrayList<>(); + when(solrClient.commit("test_collection")).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", emptyDocs); + + assertEquals(0, result); + verify(solrClient, never()).add(anyString(), any(List.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexDocuments_WhenCommitFails_ShouldPropagateException() throws Exception { + List docs = createMockDocuments(2); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenThrow(new IOException("Commit failed")); + + assertThrows(IOException.class, () -> { + indexingService.indexDocuments("test_collection", docs); + }); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexDocuments_ShouldBatchCorrectly() throws Exception { + List docs = createMockDocuments(1000); + when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", docs); + + assertEquals(1000, result); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(solrClient).add(eq("test_collection"), captor.capture()); + assertEquals(1000, captor.getValue().size()); + verify(solrClient).commit("test_collection"); + } + + @Test + void indexJsonDocuments_WhenSolrClientThrowsException_ShouldPropagateException() throws Exception { + String json = "[{\"id\":\"1\"}]"; + List mockDocs = createMockDocuments(1); + when(indexingDocumentCreator.createSchemalessDocumentsFromJson(json)).thenReturn(mockDocs); + when(solrClient.add(eq("test_collection"), any(List.class))) + .thenThrow(new SolrServerException("Solr connection error")); + when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))) + .thenThrow(new SolrServerException("Solr connection error")); + when(solrClient.commit("test_collection")).thenReturn(null); + + indexingService.indexJsonDocuments("test_collection", json); + + verify(solrClient).add(eq("test_collection"), any(List.class)); + verify(solrClient).add(eq("test_collection"), any(SolrInputDocument.class)); + } + + @Test + void indexCsvDocuments_WhenSolrClientThrowsIOException_ShouldPropagateException() throws Exception { + String csv = "id,title\n1,Test"; + List mockDocs = createMockDocuments(1); + when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv)).thenReturn(mockDocs); + when(solrClient.add(eq("test_collection"), any(List.class))).thenThrow(new IOException("Network error")); + when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))) + .thenThrow(new IOException("Network error")); + when(solrClient.commit("test_collection")).thenReturn(null); + + indexingService.indexCsvDocuments("test_collection", csv); + + verify(solrClient).add(eq("test_collection"), any(List.class)); + verify(solrClient).add(eq("test_collection"), any(SolrInputDocument.class)); + } + + @Test + void indexDocuments_WithRuntimeException_ShouldRetryIndividually() throws Exception { + List docs = createMockDocuments(2); + + when(solrClient.add(eq("test_collection"), any(List.class))) + .thenThrow(new RuntimeException("Unexpected error")); + + when(solrClient.add(eq("test_collection"), any(SolrInputDocument.class))).thenReturn(null); + when(solrClient.commit("test_collection")).thenReturn(null); + + int result = indexingService.indexDocuments("test_collection", docs); + + assertEquals(2, result); + verify(solrClient).add(eq("test_collection"), any(Collection.class)); + verify(solrClient, times(2)).add(eq("test_collection"), any(SolrInputDocument.class)); + verify(solrClient).commit("test_collection"); + } + + private List createMockDocuments(int count) { + List docs = new ArrayList<>(); + for (int i = 0; i < count; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "doc" + i); + doc.addField("title", "Document " + i); + docs.add(doc); + } + return docs; + } } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java index a8a6b63..77bdcb7 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java @@ -30,510 +30,463 @@ /** * Test class for XML indexing functionality in IndexingService. * - *

This test verifies that the IndexingService can correctly parse XML data and convert it into - * SolrInputDocument objects using the schema-less approach. + *

+ * This test verifies that the IndexingService can correctly parse XML data and + * convert it into SolrInputDocument objects using the schema-less approach. */ @SpringBootTest @TestPropertySource(locations = "classpath:application.properties") class XmlIndexingTest { - @Autowired private IndexingDocumentCreator indexingDocumentCreator; - - @Test - void testCreateSchemalessDocumentsFromXmlSingleDocument() throws Exception { - // Given - - String xmlData = - """ - - A Game of Thrones - - George R.R. Martin - george@example.com - - 7.99 - true - fantasy - - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); - - // Then - assertThat(documents).hasSize(1); - - SolrInputDocument doc = documents.getFirst(); - assertThat(doc.getFieldValue("id_attr")).isEqualTo("123"); - assertThat(doc.getFieldValue("book_title")).isEqualTo("A Game of Thrones"); - assertThat(doc.getFieldValue("book_author_name")).isEqualTo("George R.R. Martin"); - assertThat(doc.getFieldValue("book_author_email")).isEqualTo("george@example.com"); - assertThat(doc.getFieldValue("book_price")).isEqualTo("7.99"); - assertThat(doc.getFieldValue("book_instock")).isEqualTo("true"); - assertThat(doc.getFieldValue("book_genre")).isEqualTo("fantasy"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlMultipleDocuments() throws Exception { - // Given - - String xmlData = - """ - - - A Game of Thrones - George R.R. Martin - fantasy - - - Foundation - Isaac Asimov - scifi - - - Dune - Frank Herbert - scifi - - - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); - - // Then - assertThat(documents).hasSize(3); - - // Verify first document - SolrInputDocument firstDoc = documents.getFirst(); - assertThat(firstDoc.getFieldValue("id_attr")).isEqualTo("1"); - assertThat(firstDoc.getFieldValue("document_title")).isEqualTo("A Game of Thrones"); - assertThat(firstDoc.getFieldValue("document_author")).isEqualTo("George R.R. Martin"); - assertThat(firstDoc.getFieldValue("document_genre")).isEqualTo("fantasy"); - - // Verify second document - SolrInputDocument secondDoc = documents.get(1); - assertThat(secondDoc.getFieldValue("id_attr")).isEqualTo("2"); - assertThat(secondDoc.getFieldValue("document_title")).isEqualTo("Foundation"); - assertThat(secondDoc.getFieldValue("document_author")).isEqualTo("Isaac Asimov"); - assertThat(secondDoc.getFieldValue("document_genre")).isEqualTo("scifi"); - - // Verify third document - SolrInputDocument thirdDoc = documents.get(2); - assertThat(thirdDoc.getFieldValue("id_attr")).isEqualTo("3"); - assertThat(thirdDoc.getFieldValue("document_title")).isEqualTo("Dune"); - assertThat(thirdDoc.getFieldValue("document_author")).isEqualTo("Frank Herbert"); - assertThat(thirdDoc.getFieldValue("document_genre")).isEqualTo("scifi"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithAttributes() throws Exception { - // Given - - String xmlData = - """ - - Smartphone - 599.99 - Latest smartphone with advanced features - - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); - - // Then - assertThat(documents).hasSize(1); - - SolrInputDocument doc = documents.getFirst(); - assertThat(doc.getFieldValue("id_attr")).isEqualTo("P123"); - assertThat(doc.getFieldValue("category_attr")).isEqualTo("electronics"); - assertThat(doc.getFieldValue("featured_attr")).isEqualTo("true"); - assertThat(doc.getFieldValue("product_name_lang_attr")).isEqualTo("en"); - assertThat(doc.getFieldValue("product_price_currency_attr")).isEqualTo("USD"); - assertThat(doc.getFieldValue("product_name")).isEqualTo("Smartphone"); - assertThat(doc.getFieldValue("product_price")).isEqualTo("599.99"); - assertThat(doc.getFieldValue("product_description")) - .isEqualTo("Latest smartphone with advanced features"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithEmptyValues() throws Exception { - // Given - - String xmlData = - """ - - - Product One - - 19.99 - - - - Product with no name - 29.99 - - - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); - - // Then - assertThat(documents).hasSize(2); - - // First document should skip empty description - SolrInputDocument firstDoc = documents.getFirst(); - assertThat(firstDoc.getFieldValue("id_attr")).isEqualTo("1"); - assertThat(firstDoc.getFieldValue("item_name")).isEqualTo("Product One"); - assertThat(firstDoc.getFieldValue("item_description")) - .isNull(); // Empty element should not be indexed - assertThat(firstDoc.getFieldValue("item_price")).isEqualTo("19.99"); - - // Second document should skip empty name - SolrInputDocument secondDoc = documents.get(1); - assertThat(secondDoc.getFieldValue("id_attr")).isEqualTo("2"); - assertThat(secondDoc.getFieldValue("item_name")) - .isNull(); // Empty element should not be indexed - assertThat(secondDoc.getFieldValue("item_description")).isEqualTo("Product with no name"); - assertThat(secondDoc.getFieldValue("item_price")).isEqualTo("29.99"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithRepeatedElements() throws Exception { - // Given - - String xmlData = - """ - - Programming Book - John Doe - - programming - java - software - - - Technology - Education - - - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); - - // Then - assertThat(documents).hasSize(1); - - SolrInputDocument doc = documents.getFirst(); - assertThat(doc.getFieldValue("book_title")).isEqualTo("Programming Book"); - assertThat(doc.getFieldValue("book_author")).isEqualTo("John Doe"); - - // Check that repeated elements are handled properly - // Note: The current implementation processes each element separately, - // so we check for the individual tag and category fields - assertThat(doc.getFieldValue("book_tags_tag")).isNotNull(); - assertThat(doc.getFieldValue("book_categories_category")).isNotNull(); - } - - @Test - void testCreateSchemalessDocumentsFromXmlMixedContent() throws Exception { - // Given - - String xmlData = - """ -

- Mixed Content Example - - This is some text content with - emphasized text - and more content here. - - Jane Smith -
- """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); - - // Then - assertThat(documents).hasSize(1); - - SolrInputDocument doc = documents.getFirst(); - assertThat(doc.getFieldValue("article_title")).isEqualTo("Mixed Content Example"); - assertThat(doc.getFieldValue("article_author")).isEqualTo("Jane Smith"); - - // Mixed content should be handled - text content should be captured - assertThat(doc.getFieldValue("article_content")).isNotNull(); - assertThat(doc.getFieldValue("article_content_emphasis")).isEqualTo("emphasized text"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithMalformedXml() { - // Given - - String malformedXml = - """ - - Incomplete Book - <author>John Doe</author> - </book> - """; - - // When/Then - assertThatThrownBy( - () -> - indexingDocumentCreator.createSchemalessDocumentsFromXml( - malformedXml)) - .isInstanceOf(RuntimeException.class); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithInvalidCharacters() { - // Given - - String invalidXml = - """ - <book> - <title>Book with invalid character: \u0000 - John Doe - - """; - - // When/Then - assertThatThrownBy( - () -> indexingDocumentCreator.createSchemalessDocumentsFromXml(invalidXml)) - .isInstanceOf(RuntimeException.class); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithDoctype() { - // Given - - String xmlWithDoctype = - """ - - - - - ]> - - Test Book - Test Author - - """; - - // When/Then - Should fail due to XXE protection - assertThatThrownBy( - () -> - indexingDocumentCreator.createSchemalessDocumentsFromXml( - xmlWithDoctype)) - .isInstanceOf(RuntimeException.class); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithExternalEntity() { - // Given - - String xmlWithExternalEntity = - """ - - - ]> - - &external; - Test Author - - """; - - // When/Then - Should fail due to XXE protection - assertThatThrownBy( - () -> - indexingDocumentCreator.createSchemalessDocumentsFromXml( - xmlWithExternalEntity)) - .isInstanceOf(RuntimeException.class); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithNullInput() { - // Given - - // When/Then - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("XML input cannot be null or empty"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithEmptyInput() { - // Given - - // When/Then - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml("")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("XML input cannot be null or empty"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithWhitespaceOnlyInput() { - // Given - - // When/Then - assertThatThrownBy( - () -> indexingDocumentCreator.createSchemalessDocumentsFromXml(" \n\t ")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("XML input cannot be null or empty"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithLargeDocument() { - // Given - - // Create a large XML document (over 10MB) - StringBuilder largeXml = new StringBuilder(); - largeXml.append(""); - - // Add enough data to exceed the 10MB limit - String bookTemplate = - """ - - %s - %s - - """; - - // Create approximately 11MB of XML data - String longContent = "A".repeat(10000); // 10KB per book - for (int i = 0; i < 1200; i++) { // 1200 * 10KB = 12MB - largeXml.append(String.format(bookTemplate, i, "Title " + i, longContent)); - } - largeXml.append(""); - - // When/Then - assertThatThrownBy( - () -> - indexingDocumentCreator.createSchemalessDocumentsFromXml( - largeXml.toString())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("XML document too large"); - } - - @Test - void testCreateSchemalessDocumentsFromXmlWithComplexNestedStructure() throws Exception { - // Given - - String complexXml = - """ - -
- Smartphone - Teléfono inteligente - - Full HD+ - Primary camera - Front camera - - 128GB - Yes - - -
- 599.99 - - - US - EU - APAC - - true - -
- """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(complexXml); - - // Then - assertThat(documents).hasSize(1); - - SolrInputDocument doc = documents.getFirst(); - - // Verify basic attributes - assertThat(doc.getFieldValue("id_attr")).isEqualTo("123"); - assertThat(doc.getFieldValue("category_attr")).isEqualTo("electronics"); - - // Verify nested structure flattening - assertThat(doc.getFieldValue("product_details_name_lang_attr")).isNotNull(); - assertThat(doc.getFieldValue("product_details_specifications_screen_size_attr")) - .isEqualTo("6.1"); - assertThat(doc.getFieldValue("product_details_specifications_screen_type_attr")) - .isEqualTo("OLED"); - assertThat(doc.getFieldValue("product_details_specifications_screen")) - .isEqualTo("Full HD+"); - - // Verify multiple similar elements - assertThat(doc.getFieldValue("product_details_specifications_camera_type_attr")) - .isNotNull(); - assertThat(doc.getFieldValue("product_details_specifications_camera_resolution_attr")) - .isNotNull(); - - // Verify deeply nested elements - assertThat(doc.getFieldValue("product_details_specifications_storage_internal")) - .isEqualTo("128GB"); - assertThat(doc.getFieldValue("product_details_specifications_storage_expandable")) - .isEqualTo("Yes"); - - // Verify pricing and availability - assertThat(doc.getFieldValue("product_pricing_currency_attr")).isEqualTo("USD"); - assertThat(doc.getFieldValue("product_pricing")).isEqualTo("599.99"); - assertThat(doc.getFieldValue("product_availability_instock")).isEqualTo("true"); - assertThat(doc.getFieldValue("product_availability_regions_region")).isNotNull(); - } - - @Test - void testFieldNameSanitization() throws Exception { - // Given - - String xmlWithSpecialChars = - """ - - Test Product - 99.99 - electronics - value - dashed value - uppercase value - - """; - - // When - List documents = - indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithSpecialChars); - - // Then - assertThat(documents).hasSize(1); - - SolrInputDocument doc = documents.getFirst(); - - // Verify field name sanitization - assertThat(doc.getFieldValue("id_attr")).isEqualTo("123"); - assertThat(doc.getFieldValue("product_data_product_name")).isEqualTo("Test Product"); - assertThat(doc.getFieldValue("product_data_price_usd")).isEqualTo("99.99"); - assertThat(doc.getFieldValue("product_data_category_type")).isEqualTo("electronics"); - assertThat(doc.getFieldValue("product_data_field_with_multiple_underscores")) - .isEqualTo("value"); - assertThat(doc.getFieldValue("product_data_field_with_dashes")).isEqualTo("dashed value"); - assertThat(doc.getFieldValue("product_data_uppercase_field")).isEqualTo("uppercase value"); - } + @Autowired + private IndexingDocumentCreator indexingDocumentCreator; + + @Test + void testCreateSchemalessDocumentsFromXmlSingleDocument() throws Exception { + // Given + + String xmlData = """ + + A Game of Thrones + + George R.R. Martin + george@example.com + + 7.99 + true + fantasy + + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + + // Then + assertThat(documents).hasSize(1); + + SolrInputDocument doc = documents.getFirst(); + assertThat(doc.getFieldValue("id_attr")).isEqualTo("123"); + assertThat(doc.getFieldValue("book_title")).isEqualTo("A Game of Thrones"); + assertThat(doc.getFieldValue("book_author_name")).isEqualTo("George R.R. Martin"); + assertThat(doc.getFieldValue("book_author_email")).isEqualTo("george@example.com"); + assertThat(doc.getFieldValue("book_price")).isEqualTo("7.99"); + assertThat(doc.getFieldValue("book_instock")).isEqualTo("true"); + assertThat(doc.getFieldValue("book_genre")).isEqualTo("fantasy"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlMultipleDocuments() throws Exception { + // Given + + String xmlData = """ + + + A Game of Thrones + George R.R. Martin + fantasy + + + Foundation + Isaac Asimov + scifi + + + Dune + Frank Herbert + scifi + + + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + + // Then + assertThat(documents).hasSize(3); + + // Verify first document + SolrInputDocument firstDoc = documents.getFirst(); + assertThat(firstDoc.getFieldValue("id_attr")).isEqualTo("1"); + assertThat(firstDoc.getFieldValue("document_title")).isEqualTo("A Game of Thrones"); + assertThat(firstDoc.getFieldValue("document_author")).isEqualTo("George R.R. Martin"); + assertThat(firstDoc.getFieldValue("document_genre")).isEqualTo("fantasy"); + + // Verify second document + SolrInputDocument secondDoc = documents.get(1); + assertThat(secondDoc.getFieldValue("id_attr")).isEqualTo("2"); + assertThat(secondDoc.getFieldValue("document_title")).isEqualTo("Foundation"); + assertThat(secondDoc.getFieldValue("document_author")).isEqualTo("Isaac Asimov"); + assertThat(secondDoc.getFieldValue("document_genre")).isEqualTo("scifi"); + + // Verify third document + SolrInputDocument thirdDoc = documents.get(2); + assertThat(thirdDoc.getFieldValue("id_attr")).isEqualTo("3"); + assertThat(thirdDoc.getFieldValue("document_title")).isEqualTo("Dune"); + assertThat(thirdDoc.getFieldValue("document_author")).isEqualTo("Frank Herbert"); + assertThat(thirdDoc.getFieldValue("document_genre")).isEqualTo("scifi"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithAttributes() throws Exception { + // Given + + String xmlData = """ + + Smartphone + 599.99 + Latest smartphone with advanced features + + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + + // Then + assertThat(documents).hasSize(1); + + SolrInputDocument doc = documents.getFirst(); + assertThat(doc.getFieldValue("id_attr")).isEqualTo("P123"); + assertThat(doc.getFieldValue("category_attr")).isEqualTo("electronics"); + assertThat(doc.getFieldValue("featured_attr")).isEqualTo("true"); + assertThat(doc.getFieldValue("product_name_lang_attr")).isEqualTo("en"); + assertThat(doc.getFieldValue("product_price_currency_attr")).isEqualTo("USD"); + assertThat(doc.getFieldValue("product_name")).isEqualTo("Smartphone"); + assertThat(doc.getFieldValue("product_price")).isEqualTo("599.99"); + assertThat(doc.getFieldValue("product_description")).isEqualTo("Latest smartphone with advanced features"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithEmptyValues() throws Exception { + // Given + + String xmlData = """ + + + Product One + + 19.99 + + + + Product with no name + 29.99 + + + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + + // Then + assertThat(documents).hasSize(2); + + // First document should skip empty description + SolrInputDocument firstDoc = documents.getFirst(); + assertThat(firstDoc.getFieldValue("id_attr")).isEqualTo("1"); + assertThat(firstDoc.getFieldValue("item_name")).isEqualTo("Product One"); + assertThat(firstDoc.getFieldValue("item_description")).isNull(); // Empty element should not be indexed + assertThat(firstDoc.getFieldValue("item_price")).isEqualTo("19.99"); + + // Second document should skip empty name + SolrInputDocument secondDoc = documents.get(1); + assertThat(secondDoc.getFieldValue("id_attr")).isEqualTo("2"); + assertThat(secondDoc.getFieldValue("item_name")).isNull(); // Empty element should not be indexed + assertThat(secondDoc.getFieldValue("item_description")).isEqualTo("Product with no name"); + assertThat(secondDoc.getFieldValue("item_price")).isEqualTo("29.99"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithRepeatedElements() throws Exception { + // Given + + String xmlData = """ + + Programming Book + John Doe + + programming + java + software + + + Technology + Education + + + """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + + // Then + assertThat(documents).hasSize(1); + + SolrInputDocument doc = documents.getFirst(); + assertThat(doc.getFieldValue("book_title")).isEqualTo("Programming Book"); + assertThat(doc.getFieldValue("book_author")).isEqualTo("John Doe"); + + // Check that repeated elements are handled properly + // Note: The current implementation processes each element separately, + // so we check for the individual tag and category fields + assertThat(doc.getFieldValue("book_tags_tag")).isNotNull(); + assertThat(doc.getFieldValue("book_categories_category")).isNotNull(); + } + + @Test + void testCreateSchemalessDocumentsFromXmlMixedContent() throws Exception { + // Given + + String xmlData = """ +
+ Mixed Content Example + + This is some text content with + emphasized text + and more content here. + + Jane Smith +
+ """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + + // Then + assertThat(documents).hasSize(1); + + SolrInputDocument doc = documents.getFirst(); + assertThat(doc.getFieldValue("article_title")).isEqualTo("Mixed Content Example"); + assertThat(doc.getFieldValue("article_author")).isEqualTo("Jane Smith"); + + // Mixed content should be handled - text content should be captured + assertThat(doc.getFieldValue("article_content")).isNotNull(); + assertThat(doc.getFieldValue("article_content_emphasis")).isEqualTo("emphasized text"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithMalformedXml() { + // Given + + String malformedXml = """ + + Incomplete Book + <author>John Doe</author> + </book> + """; + + // When/Then + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(malformedXml)) + .isInstanceOf(RuntimeException.class); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithInvalidCharacters() { + // Given + + String invalidXml = """ + <book> + <title>Book with invalid character: \u0000 + John Doe + + """; + + // When/Then + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(invalidXml)) + .isInstanceOf(RuntimeException.class); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithDoctype() { + // Given + + String xmlWithDoctype = """ + + + + + ]> + + Test Book + Test Author + + """; + + // When/Then - Should fail due to XXE protection + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithDoctype)) + .isInstanceOf(RuntimeException.class); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithExternalEntity() { + // Given + + String xmlWithExternalEntity = """ + + + ]> + + &external; + Test Author + + """; + + // When/Then - Should fail due to XXE protection + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithExternalEntity)) + .isInstanceOf(RuntimeException.class); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithNullInput() { + // Given + + // When/Then + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(null)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("XML input cannot be null or empty"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithEmptyInput() { + // Given + + // When/Then + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml("")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("XML input cannot be null or empty"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithWhitespaceOnlyInput() { + // Given + + // When/Then + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(" \n\t ")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("XML input cannot be null or empty"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithLargeDocument() { + // Given + + // Create a large XML document (over 10MB) + StringBuilder largeXml = new StringBuilder(); + largeXml.append(""); + + // Add enough data to exceed the 10MB limit + String bookTemplate = """ + + %s + %s + + """; + + // Create approximately 11MB of XML data + String longContent = "A".repeat(10000); // 10KB per book + for (int i = 0; i < 1200; i++) { // 1200 * 10KB = 12MB + largeXml.append(String.format(bookTemplate, i, "Title " + i, longContent)); + } + largeXml.append(""); + + // When/Then + assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(largeXml.toString())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("XML document too large"); + } + + @Test + void testCreateSchemalessDocumentsFromXmlWithComplexNestedStructure() throws Exception { + // Given + + String complexXml = """ + +
+ Smartphone + Teléfono inteligente + + Full HD+ + Primary camera + Front camera + + 128GB + Yes + + +
+ 599.99 + + + US + EU + APAC + + true + +
+ """; + + // When + List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(complexXml); + + // Then + assertThat(documents).hasSize(1); + + SolrInputDocument doc = documents.getFirst(); + + // Verify basic attributes + assertThat(doc.getFieldValue("id_attr")).isEqualTo("123"); + assertThat(doc.getFieldValue("category_attr")).isEqualTo("electronics"); + + // Verify nested structure flattening + assertThat(doc.getFieldValue("product_details_name_lang_attr")).isNotNull(); + assertThat(doc.getFieldValue("product_details_specifications_screen_size_attr")).isEqualTo("6.1"); + assertThat(doc.getFieldValue("product_details_specifications_screen_type_attr")).isEqualTo("OLED"); + assertThat(doc.getFieldValue("product_details_specifications_screen")).isEqualTo("Full HD+"); + + // Verify multiple similar elements + assertThat(doc.getFieldValue("product_details_specifications_camera_type_attr")).isNotNull(); + assertThat(doc.getFieldValue("product_details_specifications_camera_resolution_attr")).isNotNull(); + + // Verify deeply nested elements + assertThat(doc.getFieldValue("product_details_specifications_storage_internal")).isEqualTo("128GB"); + assertThat(doc.getFieldValue("product_details_specifications_storage_expandable")).isEqualTo("Yes"); + + // Verify pricing and availability + assertThat(doc.getFieldValue("product_pricing_currency_attr")).isEqualTo("USD"); + assertThat(doc.getFieldValue("product_pricing")).isEqualTo("599.99"); + assertThat(doc.getFieldValue("product_availability_instock")).isEqualTo("true"); + assertThat(doc.getFieldValue("product_availability_regions_region")).isNotNull(); + } + + @Test + void testFieldNameSanitization() throws Exception { + // Given + + String xmlWithSpecialChars = """ + + Test Product + 99.99 + electronics + value + dashed value + uppercase value + + """; + + // When + List documents = indexingDocumentCreator + .createSchemalessDocumentsFromXml(xmlWithSpecialChars); + + // Then + assertThat(documents).hasSize(1); + + SolrInputDocument doc = documents.getFirst(); + + // Verify field name sanitization + assertThat(doc.getFieldValue("id_attr")).isEqualTo("123"); + assertThat(doc.getFieldValue("product_data_product_name")).isEqualTo("Test Product"); + assertThat(doc.getFieldValue("product_data_price_usd")).isEqualTo("99.99"); + assertThat(doc.getFieldValue("product_data_category_type")).isEqualTo("electronics"); + assertThat(doc.getFieldValue("product_data_field_with_multiple_underscores")).isEqualTo("value"); + assertThat(doc.getFieldValue("product_data_field_with_dashes")).isEqualTo("dashed value"); + assertThat(doc.getFieldValue("product_data_uppercase_field")).isEqualTo("uppercase value"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java index 5f9261c..0266661 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java @@ -32,246 +32,213 @@ @Import(TestcontainersConfiguration.class) class CollectionServiceIntegrationTest { - private static final String TEST_COLLECTION = "test_collection"; - @Autowired private CollectionService collectionService; - @Autowired private SolrClient solrClient; - private static boolean initialized = false; - - @BeforeEach - void setupCollection() throws Exception { - - if (!initialized) { - // Create a test collection using the container's connection details - // Create a collection for testing - CollectionAdminRequest.Create createRequest = - CollectionAdminRequest.createCollection(TEST_COLLECTION, "_default", 1, 1); - createRequest.process(solrClient); - - // Verify collection was created successfully - CollectionAdminRequest.List listRequest = new CollectionAdminRequest.List(); - listRequest.process(solrClient); - - System.out.println("[DEBUG_LOG] Test collection created: " + TEST_COLLECTION); - initialized = true; - } - } - - @Test - void testListCollections() { - // Test listing collections - List collections = collectionService.listCollections(); - - // Print the collections for debugging - System.out.println("[DEBUG_LOG] Collections: " + collections); - - // Enhanced assertions for collections list - assertNotNull(collections, "Collections list should not be null"); - assertFalse(collections.isEmpty(), "Collections list should not be empty"); - - // Check if the test collection exists (either as exact name or as shard) - boolean testCollectionExists = - collections.contains(TEST_COLLECTION) - || collections.stream() - .anyMatch(col -> col.startsWith(TEST_COLLECTION + "_shard")); - assertTrue( - testCollectionExists, - "Collections should contain the test collection: " - + TEST_COLLECTION - + " (found: " - + collections - + ")"); - - // Verify collection names are not null or empty - for (String collection : collections) { - assertNotNull(collection, "Collection name should not be null"); - assertFalse(collection.trim().isEmpty(), "Collection name should not be empty"); - } - - // Verify expected collection characteristics - assertEquals( - collections.size(), - collections.stream().distinct().count(), - "Collection names should be unique"); - - // Verify that collections follow expected naming patterns - for (String collection : collections) { - // Collection names should either be simple names or shard names - assertTrue( - collection.matches("^[a-zA-Z0-9_]+(_shard\\d+_replica_n\\d+)?$"), - "Collection name should follow expected pattern: " + collection); - } - } - - @Test - void testGetCollectionStats() throws Exception { - // Test getting collection stats - SolrMetrics metrics = collectionService.getCollectionStats(TEST_COLLECTION); - - // Enhanced assertions for metrics - assertNotNull(metrics, "Collection stats should not be null"); - assertNotNull(metrics.timestamp(), "Timestamp should not be null"); - - // Verify index stats - assertNotNull(metrics.indexStats(), "Index stats should not be null"); - IndexStats indexStats = metrics.indexStats(); - assertNotNull(indexStats.numDocs(), "Number of documents should not be null"); - assertTrue(indexStats.numDocs() >= 0, "Number of documents should be non-negative"); - - // Verify query stats - assertNotNull(metrics.queryStats(), "Query stats should not be null"); - QueryStats queryStats = metrics.queryStats(); - assertNotNull(queryStats.queryTime(), "Query time should not be null"); - assertTrue(queryStats.queryTime() >= 0, "Query time should be non-negative"); - assertNotNull(queryStats.totalResults(), "Total results should not be null"); - assertTrue(queryStats.totalResults() >= 0, "Total results should be non-negative"); - assertNotNull(queryStats.start(), "Start should not be null"); - assertTrue(queryStats.start() >= 0, "Start should be non-negative"); - - // Verify timestamp is recent (within last 10 seconds) - long currentTime = System.currentTimeMillis(); - long timestampTime = metrics.timestamp().getTime(); - assertTrue( - currentTime - timestampTime < 10000, - "Timestamp should be recent (within 10 seconds)"); - - // Verify optional stats (cache and handler stats may be null, which is acceptable) - if (metrics.cacheStats() != null) { - CacheStats cacheStats = metrics.cacheStats(); - // Verify at least one cache type exists if cache stats are present - assertTrue( - cacheStats.queryResultCache() != null - || cacheStats.documentCache() != null - || cacheStats.filterCache() != null, - "At least one cache type should be present if cache stats exist"); - } - - if (metrics.handlerStats() != null) { - HandlerStats handlerStats = metrics.handlerStats(); - // Verify at least one handler type exists if handler stats are present - assertTrue( - handlerStats.selectHandler() != null || handlerStats.updateHandler() != null, - "At least one handler type should be present if handler stats exist"); - } - } - - @Test - void testCheckHealthHealthy() { - // Test checking health of a valid collection - SolrHealthStatus status = collectionService.checkHealth(TEST_COLLECTION); - - // Print the status for debugging - System.out.println("[DEBUG_LOG] Health status for valid collection: " + status); - - // Enhanced assertions for healthy collection - assertNotNull(status, "Health status should not be null"); - assertTrue(status.isHealthy(), "Collection should be healthy"); - - // Verify response time - assertNotNull(status.responseTime(), "Response time should not be null"); - assertTrue(status.responseTime() >= 0, "Response time should be non-negative"); - assertTrue( - status.responseTime() < 30000, "Response time should be reasonable (< 30 seconds)"); - - // Verify document count - assertNotNull(status.totalDocuments(), "Total documents should not be null"); - assertTrue(status.totalDocuments() >= 0, "Total documents should be non-negative"); - - // Verify timestamp - assertNotNull(status.lastChecked(), "Last checked timestamp should not be null"); - long currentTime = System.currentTimeMillis(); - long lastCheckedTime = status.lastChecked().getTime(); - assertTrue( - currentTime - lastCheckedTime < 5000, - "Last checked timestamp should be very recent (within 5 seconds)"); - - // Verify no error message for healthy collection - assertNull(status.errorMessage(), "Error message should be null for healthy collection"); - - // Verify string representation contains meaningful information - String statusString = status.toString(); - if (statusString != null) { - assertTrue( - statusString.contains("healthy") || statusString.contains("true"), - "Status string should indicate healthy state"); - } - } - - @Test - void testCheckHealthUnhealthy() { - // Test checking health of an invalid collection - String nonExistentCollection = "non_existent_collection"; - SolrHealthStatus status = collectionService.checkHealth(nonExistentCollection); - - // Print the status for debugging - System.out.println("[DEBUG_LOG] Health status for invalid collection: " + status); - - // Enhanced assertions for unhealthy collection - assertNotNull(status, "Health status should not be null"); - assertFalse(status.isHealthy(), "Collection should not be healthy"); - - // Verify timestamp - assertNotNull(status.lastChecked(), "Last checked timestamp should not be null"); - long currentTime = System.currentTimeMillis(); - long lastCheckedTime = status.lastChecked().getTime(); - assertTrue( - currentTime - lastCheckedTime < 5000, - "Last checked timestamp should be very recent (within 5 seconds)"); - - // Verify error message - assertNotNull( - status.errorMessage(), "Error message should not be null for unhealthy collection"); - assertFalse( - status.errorMessage().trim().isEmpty(), - "Error message should not be empty for unhealthy collection"); - - // Verify that performance metrics are null for unhealthy collection - assertNull(status.responseTime(), "Response time should be null for unhealthy collection"); - assertNull( - status.totalDocuments(), "Total documents should be null for unhealthy collection"); - - // Verify error message contains meaningful information - String errorMessage = status.errorMessage().toLowerCase(); - assertTrue( - errorMessage.contains("collection") - || errorMessage.contains("not found") - || errorMessage.contains("error") - || errorMessage.contains("fail"), - "Error message should contain meaningful error information"); - - // Verify string representation indicates unhealthy state - String statusString = status.toString(); - if (statusString != null) { - assertTrue( - statusString.contains("false") - || statusString.contains("unhealthy") - || statusString.contains("error"), - "Status string should indicate unhealthy state"); - } - } - - @Test - void testCollectionNameExtraction() { - // Test collection name extraction functionality - assertEquals( - TEST_COLLECTION, - collectionService.extractCollectionName(TEST_COLLECTION), - "Regular collection name should be returned as-is"); - - assertEquals( - "films", - collectionService.extractCollectionName("films_shard1_replica_n1"), - "Shard name should be extracted to base collection name"); - - assertEquals( - "products", - collectionService.extractCollectionName("products_shard2_replica_n3"), - "Complex shard name should be extracted correctly"); - - assertNull(collectionService.extractCollectionName(null), "Null input should return null"); - - assertEquals( - "", - collectionService.extractCollectionName(""), - "Empty string should return empty string"); - } + private static final String TEST_COLLECTION = "test_collection"; + @Autowired + private CollectionService collectionService; + @Autowired + private SolrClient solrClient; + private static boolean initialized = false; + + @BeforeEach + void setupCollection() throws Exception { + + if (!initialized) { + // Create a test collection using the container's connection details + // Create a collection for testing + CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection(TEST_COLLECTION, + "_default", 1, 1); + createRequest.process(solrClient); + + // Verify collection was created successfully + CollectionAdminRequest.List listRequest = new CollectionAdminRequest.List(); + listRequest.process(solrClient); + + System.out.println("[DEBUG_LOG] Test collection created: " + TEST_COLLECTION); + initialized = true; + } + } + + @Test + void testListCollections() { + // Test listing collections + List collections = collectionService.listCollections(); + + // Print the collections for debugging + System.out.println("[DEBUG_LOG] Collections: " + collections); + + // Enhanced assertions for collections list + assertNotNull(collections, "Collections list should not be null"); + assertFalse(collections.isEmpty(), "Collections list should not be empty"); + + // Check if the test collection exists (either as exact name or as shard) + boolean testCollectionExists = collections.contains(TEST_COLLECTION) + || collections.stream().anyMatch(col -> col.startsWith(TEST_COLLECTION + "_shard")); + assertTrue(testCollectionExists, + "Collections should contain the test collection: " + TEST_COLLECTION + " (found: " + collections + ")"); + + // Verify collection names are not null or empty + for (String collection : collections) { + assertNotNull(collection, "Collection name should not be null"); + assertFalse(collection.trim().isEmpty(), "Collection name should not be empty"); + } + + // Verify expected collection characteristics + assertEquals(collections.size(), collections.stream().distinct().count(), "Collection names should be unique"); + + // Verify that collections follow expected naming patterns + for (String collection : collections) { + // Collection names should either be simple names or shard names + assertTrue(collection.matches("^[a-zA-Z0-9_]+(_shard\\d+_replica_n\\d+)?$"), + "Collection name should follow expected pattern: " + collection); + } + } + + @Test + void testGetCollectionStats() throws Exception { + // Test getting collection stats + SolrMetrics metrics = collectionService.getCollectionStats(TEST_COLLECTION); + + // Enhanced assertions for metrics + assertNotNull(metrics, "Collection stats should not be null"); + assertNotNull(metrics.timestamp(), "Timestamp should not be null"); + + // Verify index stats + assertNotNull(metrics.indexStats(), "Index stats should not be null"); + IndexStats indexStats = metrics.indexStats(); + assertNotNull(indexStats.numDocs(), "Number of documents should not be null"); + assertTrue(indexStats.numDocs() >= 0, "Number of documents should be non-negative"); + + // Verify query stats + assertNotNull(metrics.queryStats(), "Query stats should not be null"); + QueryStats queryStats = metrics.queryStats(); + assertNotNull(queryStats.queryTime(), "Query time should not be null"); + assertTrue(queryStats.queryTime() >= 0, "Query time should be non-negative"); + assertNotNull(queryStats.totalResults(), "Total results should not be null"); + assertTrue(queryStats.totalResults() >= 0, "Total results should be non-negative"); + assertNotNull(queryStats.start(), "Start should not be null"); + assertTrue(queryStats.start() >= 0, "Start should be non-negative"); + + // Verify timestamp is recent (within last 10 seconds) + long currentTime = System.currentTimeMillis(); + long timestampTime = metrics.timestamp().getTime(); + assertTrue(currentTime - timestampTime < 10000, "Timestamp should be recent (within 10 seconds)"); + + // Verify optional stats (cache and handler stats may be null, which is + // acceptable) + if (metrics.cacheStats() != null) { + CacheStats cacheStats = metrics.cacheStats(); + // Verify at least one cache type exists if cache stats are present + assertTrue( + cacheStats.queryResultCache() != null || cacheStats.documentCache() != null + || cacheStats.filterCache() != null, + "At least one cache type should be present if cache stats exist"); + } + + if (metrics.handlerStats() != null) { + HandlerStats handlerStats = metrics.handlerStats(); + // Verify at least one handler type exists if handler stats are present + assertTrue(handlerStats.selectHandler() != null || handlerStats.updateHandler() != null, + "At least one handler type should be present if handler stats exist"); + } + } + + @Test + void testCheckHealthHealthy() { + // Test checking health of a valid collection + SolrHealthStatus status = collectionService.checkHealth(TEST_COLLECTION); + + // Print the status for debugging + System.out.println("[DEBUG_LOG] Health status for valid collection: " + status); + + // Enhanced assertions for healthy collection + assertNotNull(status, "Health status should not be null"); + assertTrue(status.isHealthy(), "Collection should be healthy"); + + // Verify response time + assertNotNull(status.responseTime(), "Response time should not be null"); + assertTrue(status.responseTime() >= 0, "Response time should be non-negative"); + assertTrue(status.responseTime() < 30000, "Response time should be reasonable (< 30 seconds)"); + + // Verify document count + assertNotNull(status.totalDocuments(), "Total documents should not be null"); + assertTrue(status.totalDocuments() >= 0, "Total documents should be non-negative"); + + // Verify timestamp + assertNotNull(status.lastChecked(), "Last checked timestamp should not be null"); + long currentTime = System.currentTimeMillis(); + long lastCheckedTime = status.lastChecked().getTime(); + assertTrue(currentTime - lastCheckedTime < 5000, + "Last checked timestamp should be very recent (within 5 seconds)"); + + // Verify no error message for healthy collection + assertNull(status.errorMessage(), "Error message should be null for healthy collection"); + + // Verify string representation contains meaningful information + String statusString = status.toString(); + if (statusString != null) { + assertTrue(statusString.contains("healthy") || statusString.contains("true"), + "Status string should indicate healthy state"); + } + } + + @Test + void testCheckHealthUnhealthy() { + // Test checking health of an invalid collection + String nonExistentCollection = "non_existent_collection"; + SolrHealthStatus status = collectionService.checkHealth(nonExistentCollection); + + // Print the status for debugging + System.out.println("[DEBUG_LOG] Health status for invalid collection: " + status); + + // Enhanced assertions for unhealthy collection + assertNotNull(status, "Health status should not be null"); + assertFalse(status.isHealthy(), "Collection should not be healthy"); + + // Verify timestamp + assertNotNull(status.lastChecked(), "Last checked timestamp should not be null"); + long currentTime = System.currentTimeMillis(); + long lastCheckedTime = status.lastChecked().getTime(); + assertTrue(currentTime - lastCheckedTime < 5000, + "Last checked timestamp should be very recent (within 5 seconds)"); + + // Verify error message + assertNotNull(status.errorMessage(), "Error message should not be null for unhealthy collection"); + assertFalse(status.errorMessage().trim().isEmpty(), + "Error message should not be empty for unhealthy collection"); + + // Verify that performance metrics are null for unhealthy collection + assertNull(status.responseTime(), "Response time should be null for unhealthy collection"); + assertNull(status.totalDocuments(), "Total documents should be null for unhealthy collection"); + + // Verify error message contains meaningful information + String errorMessage = status.errorMessage().toLowerCase(); + assertTrue( + errorMessage.contains("collection") || errorMessage.contains("not found") + || errorMessage.contains("error") || errorMessage.contains("fail"), + "Error message should contain meaningful error information"); + + // Verify string representation indicates unhealthy state + String statusString = status.toString(); + if (statusString != null) { + assertTrue(statusString.contains("false") || statusString.contains("unhealthy") + || statusString.contains("error"), "Status string should indicate unhealthy state"); + } + } + + @Test + void testCollectionNameExtraction() { + // Test collection name extraction functionality + assertEquals(TEST_COLLECTION, collectionService.extractCollectionName(TEST_COLLECTION), + "Regular collection name should be returned as-is"); + + assertEquals("films", collectionService.extractCollectionName("films_shard1_replica_n1"), + "Shard name should be extracted to base collection name"); + + assertEquals("products", collectionService.extractCollectionName("products_shard2_replica_n3"), + "Complex shard name should be extracted correctly"); + + assertNull(collectionService.extractCollectionName(null), "Null input should return null"); + + assertEquals("", collectionService.extractCollectionName(""), "Empty string should return empty string"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java index 02fccd0..a5afc42 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java @@ -44,869 +44,851 @@ @ExtendWith(MockitoExtension.class) class CollectionServiceTest { - @Mock private SolrClient solrClient; + @Mock + private SolrClient solrClient; + + @Mock + private CloudSolrClient cloudSolrClient; + + @Mock + private QueryResponse queryResponse; + + @Mock + private LukeResponse lukeResponse; + + @Mock + private SolrPingResponse pingResponse; + + private CollectionService collectionService; + + @BeforeEach + void setUp() { + collectionService = new CollectionService(solrClient); + } + + // Constructor tests + @Test + void constructor_ShouldInitializeWithSolrClient() { + assertNotNull(collectionService); + } + + @Test + void listCollections_WithCloudSolrClient_ShouldReturnCollections() throws Exception { + // Given - This test verifies the service can be constructed with + // CloudSolrClient + CollectionService cloudService = new CollectionService(cloudSolrClient); + + // Note: This test cannot fully exercise listCollections() because it requires + // mocking static methods in CollectionAdminRequest which requires PowerMock or + // Mockito inline. The actual behavior is tested in integration tests. + + // When/Then - Verify service construction + assertNotNull(cloudService, "Should be able to construct service with CloudSolrClient"); + } + + @Test + void listCollections_WhenExceptionOccurs_ShouldReturnEmptyList() throws Exception { + // Note: This test cannot fully exercise listCollections() with mock SolrClient + // because it requires mocking CoreAdminRequest processing. The actual error + // handling behavior is tested in integration tests. + + // Given/When - Using regular SolrClient (non-cloud) which will attempt + // CoreAdmin + // The mock doesn't have a real CoreAdmin implementation + + // Then - Verify service is constructed + assertNotNull(collectionService, "Service should be constructed successfully"); + } + + // Collection name extraction tests + @Test + void extractCollectionName_WithShardName_ShouldExtractCollectionName() { + // Given + String shardName = "films_shard1_replica_n1"; + + // When + String result = collectionService.extractCollectionName(shardName); + + // Then + assertEquals("films", result); + } + + @Test + void extractCollectionName_WithMultipleShards_ShouldExtractCorrectly() { + // Given & When & Then + assertEquals("products", collectionService.extractCollectionName("products_shard2_replica_n3")); + assertEquals("users", collectionService.extractCollectionName("users_shard5_replica_n10")); + } + + @Test + void extractCollectionName_WithSimpleCollectionName_ShouldReturnUnchanged() { + // Given + String simpleName = "simple_collection"; + + // When + String result = collectionService.extractCollectionName(simpleName); + + // Then + assertEquals("simple_collection", result); + } + + @Test + void extractCollectionName_WithNullInput_ShouldReturnNull() { + // When + String result = collectionService.extractCollectionName(null); + + // Then + assertNull(result); + } + + @Test + void extractCollectionName_WithEmptyString_ShouldReturnEmptyString() { + // When + String result = collectionService.extractCollectionName(""); + + // Then + assertEquals("", result); + } + + @Test + void extractCollectionName_WithCollectionNameContainingUnderscore_ShouldOnlyExtractBeforeShard() { + // Given - collection name itself contains underscore + String complexName = "my_complex_collection_shard1_replica_n1"; + + // When + String result = collectionService.extractCollectionName(complexName); + + // Then + assertEquals("my_complex_collection", result); + } + + @Test + void extractCollectionName_EdgeCases_ShouldHandleCorrectly() { + // Test various edge cases + assertEquals("a", collectionService.extractCollectionName("a_shard1")); + assertEquals("collection", collectionService.extractCollectionName("collection_shard")); + assertEquals("test_name", collectionService.extractCollectionName("test_name")); + assertEquals("", collectionService.extractCollectionName("_shard1")); + } + + @Test + void extractCollectionName_WithShardInMiddleOfName_ShouldExtractCorrectly() { + // Given - "shard" appears in collection name but not as suffix pattern + String name = "resharding_tasks"; + + // When + String result = collectionService.extractCollectionName(name); + + // Then + assertEquals("resharding_tasks", result, "Should not extract when '_shard' is not followed by number"); + } + + @Test + void extractCollectionName_WithMultipleOccurrencesOfShard_ShouldUseFirst() { + // Given + String name = "data_shard1_shard2_replica_n1"; + + // When + String result = collectionService.extractCollectionName(name); + + // Then + assertEquals("data", result, "Should use first occurrence of '_shard'"); + } + + // Health check tests + @Test + void checkHealth_WithHealthyCollection_ShouldReturnHealthyStatus() throws Exception { + // Given + SolrDocumentList docList = new SolrDocumentList(); + docList.setNumFound(100); + + when(solrClient.ping("test_collection")).thenReturn(pingResponse); + when(pingResponse.getElapsedTime()).thenReturn(10L); + when(solrClient.query(eq("test_collection"), any())).thenReturn(queryResponse); + when(queryResponse.getResults()).thenReturn(docList); + + // When + SolrHealthStatus result = collectionService.checkHealth("test_collection"); + + // Then + assertNotNull(result); + assertTrue(result.isHealthy()); + assertNull(result.errorMessage()); + assertEquals(10L, result.responseTime()); + assertEquals(100L, result.totalDocuments()); + } + + @Test + void checkHealth_WithUnhealthyCollection_ShouldReturnUnhealthyStatus() throws Exception { + // Given + when(solrClient.ping("unhealthy_collection")).thenThrow(new SolrServerException("Connection failed")); + + // When + SolrHealthStatus result = collectionService.checkHealth("unhealthy_collection"); + + // Then + assertNotNull(result); + assertFalse(result.isHealthy()); + assertNotNull(result.errorMessage()); + assertTrue(result.errorMessage().contains("Connection failed")); + assertNull(result.responseTime()); + assertNull(result.totalDocuments()); + } + + @Test + void checkHealth_WhenPingSucceedsButQueryFails_ShouldReturnUnhealthyStatus() throws Exception { + // Given + when(solrClient.ping("test_collection")).thenReturn(pingResponse); + when(solrClient.query(eq("test_collection"), any())).thenThrow(new IOException("Query failed")); + + // When + SolrHealthStatus result = collectionService.checkHealth("test_collection"); + + // Then + assertNotNull(result); + assertFalse(result.isHealthy()); + assertNotNull(result.errorMessage()); + assertTrue(result.errorMessage().contains("Query failed")); + } + + @Test + void checkHealth_WithEmptyCollection_ShouldReturnHealthyWithZeroDocuments() throws Exception { + // Given + SolrDocumentList emptyDocList = new SolrDocumentList(); + emptyDocList.setNumFound(0); + + when(solrClient.ping("empty_collection")).thenReturn(pingResponse); + when(pingResponse.getElapsedTime()).thenReturn(5L); + when(solrClient.query(eq("empty_collection"), any())).thenReturn(queryResponse); + when(queryResponse.getResults()).thenReturn(emptyDocList); + + // When + SolrHealthStatus result = collectionService.checkHealth("empty_collection"); + + // Then + assertNotNull(result); + assertTrue(result.isHealthy()); + assertEquals(0L, result.totalDocuments()); + assertEquals(5, result.responseTime()); + } + + @Test + void checkHealth_WithSlowResponse_ShouldCaptureResponseTime() throws Exception { + // Given + SolrDocumentList docList = new SolrDocumentList(); + docList.setNumFound(1000); + + when(solrClient.ping("slow_collection")).thenReturn(pingResponse); + when(pingResponse.getElapsedTime()).thenReturn(5000L); // 5 seconds + when(solrClient.query(eq("slow_collection"), any())).thenReturn(queryResponse); + when(queryResponse.getResults()).thenReturn(docList); + + // When + SolrHealthStatus result = collectionService.checkHealth("slow_collection"); + + // Then + assertNotNull(result); + assertTrue(result.isHealthy()); + assertEquals(5000, result.responseTime()); + assertTrue(result.responseTime() > 1000, "Should capture slow response time"); + } + + @Test + void checkHealth_IOException() throws Exception { + when(solrClient.ping("error_collection")).thenThrow(new IOException("Network error")); + + SolrHealthStatus result = collectionService.checkHealth("error_collection"); + + assertFalse(result.isHealthy()); + assertTrue(result.errorMessage().contains("Network error")); + assertNull(result.responseTime()); + } + + // Query stats tests + @Test + void buildQueryStats_WithValidResponse_ShouldExtractStats() { + // Given + SolrDocumentList docList = new SolrDocumentList(); + docList.setNumFound(250); + docList.setStart(0); + docList.setMaxScore(1.5f); + + when(queryResponse.getQTime()).thenReturn(25); + when(queryResponse.getResults()).thenReturn(docList); + + // When + QueryStats result = collectionService.buildQueryStats(queryResponse); + + // Then + assertNotNull(result); + assertEquals(25, result.queryTime()); + assertEquals(250, result.totalResults()); + assertEquals(0, result.start()); + assertEquals(1.5f, result.maxScore()); + } + + @Test + void buildQueryStats_WithNullMaxScore_ShouldHandleGracefully() { + // Given + SolrDocumentList docList = new SolrDocumentList(); + docList.setNumFound(100); + docList.setStart(10); + docList.setMaxScore(null); + + when(queryResponse.getQTime()).thenReturn(15); + when(queryResponse.getResults()).thenReturn(docList); + + // When + QueryStats result = collectionService.buildQueryStats(queryResponse); + + // Then + assertNotNull(result); + assertEquals(15, result.queryTime()); + assertEquals(100, result.totalResults()); + assertEquals(10, result.start()); + assertNull(result.maxScore()); + } + + // Index stats tests + @Test + void buildIndexStats_ShouldExtractStats() { + NamedList indexInfo = new NamedList<>(); + indexInfo.add("segmentCount", 5); + when(lukeResponse.getIndexInfo()).thenReturn(indexInfo); + when(lukeResponse.getNumDocs()).thenReturn(1000); + + IndexStats result = collectionService.buildIndexStats(lukeResponse); + + assertEquals(1000, result.numDocs()); + assertEquals(5, result.segmentCount()); + } + + @Test + void buildIndexStats_WithNullSegmentCount() { + NamedList indexInfo = new NamedList<>(); + when(lukeResponse.getIndexInfo()).thenReturn(indexInfo); + when(lukeResponse.getNumDocs()).thenReturn(1000); + + IndexStats result = collectionService.buildIndexStats(lukeResponse); + + assertEquals(1000, result.numDocs()); + assertNull(result.segmentCount()); + } + + // Collection validation tests + @Test + void getCollectionStats_NotFound() { + CollectionService spyService = spy(collectionService); + doReturn(Collections.emptyList()).when(spyService).listCollections(); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> spyService.getCollectionStats("non_existent")); + + assertTrue(exception.getMessage().contains("Collection not found: non_existent")); + } + + @Test + void validateCollectionExists() throws Exception { + CollectionService spyService = spy(collectionService); + List collections = Arrays.asList("collection1", "films_shard1_replica_n1"); + doReturn(collections).when(spyService).listCollections(); - @Mock private CloudSolrClient cloudSolrClient; - - @Mock private QueryResponse queryResponse; + Method method = CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); + method.setAccessible(true); - @Mock private LukeResponse lukeResponse; - - @Mock private SolrPingResponse pingResponse; + assertTrue((boolean) method.invoke(spyService, "collection1")); + assertTrue((boolean) method.invoke(spyService, "films")); + assertFalse((boolean) method.invoke(spyService, "non_existent")); + } + + @Test + void validateCollectionExists_WithException() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Collections.emptyList()).when(spyService).listCollections(); - private CollectionService collectionService; - - @BeforeEach - void setUp() { - collectionService = new CollectionService(solrClient); - } - - // Constructor tests - @Test - void constructor_ShouldInitializeWithSolrClient() { - assertNotNull(collectionService); - } - - @Test - void listCollections_WithCloudSolrClient_ShouldReturnCollections() throws Exception { - // Given - This test verifies the service can be constructed with CloudSolrClient - CollectionService cloudService = new CollectionService(cloudSolrClient); - - // Note: This test cannot fully exercise listCollections() because it requires - // mocking static methods in CollectionAdminRequest which requires PowerMock or - // Mockito inline. The actual behavior is tested in integration tests. - - // When/Then - Verify service construction - assertNotNull(cloudService, "Should be able to construct service with CloudSolrClient"); - } - - @Test - void listCollections_WhenExceptionOccurs_ShouldReturnEmptyList() throws Exception { - // Note: This test cannot fully exercise listCollections() with mock SolrClient - // because it requires mocking CoreAdminRequest processing. The actual error - // handling behavior is tested in integration tests. - - // Given/When - Using regular SolrClient (non-cloud) which will attempt CoreAdmin - // The mock doesn't have a real CoreAdmin implementation - - // Then - Verify service is constructed - assertNotNull(collectionService, "Service should be constructed successfully"); - } - - // Collection name extraction tests - @Test - void extractCollectionName_WithShardName_ShouldExtractCollectionName() { - // Given - String shardName = "films_shard1_replica_n1"; - - // When - String result = collectionService.extractCollectionName(shardName); - - // Then - assertEquals("films", result); - } - - @Test - void extractCollectionName_WithMultipleShards_ShouldExtractCorrectly() { - // Given & When & Then - assertEquals( - "products", collectionService.extractCollectionName("products_shard2_replica_n3")); - assertEquals("users", collectionService.extractCollectionName("users_shard5_replica_n10")); - } - - @Test - void extractCollectionName_WithSimpleCollectionName_ShouldReturnUnchanged() { - // Given - String simpleName = "simple_collection"; - - // When - String result = collectionService.extractCollectionName(simpleName); - - // Then - assertEquals("simple_collection", result); - } - - @Test - void extractCollectionName_WithNullInput_ShouldReturnNull() { - // When - String result = collectionService.extractCollectionName(null); - - // Then - assertNull(result); - } - - @Test - void extractCollectionName_WithEmptyString_ShouldReturnEmptyString() { - // When - String result = collectionService.extractCollectionName(""); - - // Then - assertEquals("", result); - } - - @Test - void - extractCollectionName_WithCollectionNameContainingUnderscore_ShouldOnlyExtractBeforeShard() { - // Given - collection name itself contains underscore - String complexName = "my_complex_collection_shard1_replica_n1"; - - // When - String result = collectionService.extractCollectionName(complexName); - - // Then - assertEquals("my_complex_collection", result); - } - - @Test - void extractCollectionName_EdgeCases_ShouldHandleCorrectly() { - // Test various edge cases - assertEquals("a", collectionService.extractCollectionName("a_shard1")); - assertEquals("collection", collectionService.extractCollectionName("collection_shard")); - assertEquals("test_name", collectionService.extractCollectionName("test_name")); - assertEquals("", collectionService.extractCollectionName("_shard1")); - } - - @Test - void extractCollectionName_WithShardInMiddleOfName_ShouldExtractCorrectly() { - // Given - "shard" appears in collection name but not as suffix pattern - String name = "resharding_tasks"; - - // When - String result = collectionService.extractCollectionName(name); - - // Then - assertEquals( - "resharding_tasks", - result, - "Should not extract when '_shard' is not followed by number"); - } - - @Test - void extractCollectionName_WithMultipleOccurrencesOfShard_ShouldUseFirst() { - // Given - String name = "data_shard1_shard2_replica_n1"; - - // When - String result = collectionService.extractCollectionName(name); - - // Then - assertEquals("data", result, "Should use first occurrence of '_shard'"); - } - - // Health check tests - @Test - void checkHealth_WithHealthyCollection_ShouldReturnHealthyStatus() throws Exception { - // Given - SolrDocumentList docList = new SolrDocumentList(); - docList.setNumFound(100); - - when(solrClient.ping("test_collection")).thenReturn(pingResponse); - when(pingResponse.getElapsedTime()).thenReturn(10L); - when(solrClient.query(eq("test_collection"), any())).thenReturn(queryResponse); - when(queryResponse.getResults()).thenReturn(docList); - - // When - SolrHealthStatus result = collectionService.checkHealth("test_collection"); - - // Then - assertNotNull(result); - assertTrue(result.isHealthy()); - assertNull(result.errorMessage()); - assertEquals(10L, result.responseTime()); - assertEquals(100L, result.totalDocuments()); - } - - @Test - void checkHealth_WithUnhealthyCollection_ShouldReturnUnhealthyStatus() throws Exception { - // Given - when(solrClient.ping("unhealthy_collection")) - .thenThrow(new SolrServerException("Connection failed")); - - // When - SolrHealthStatus result = collectionService.checkHealth("unhealthy_collection"); - - // Then - assertNotNull(result); - assertFalse(result.isHealthy()); - assertNotNull(result.errorMessage()); - assertTrue(result.errorMessage().contains("Connection failed")); - assertNull(result.responseTime()); - assertNull(result.totalDocuments()); - } - - @Test - void checkHealth_WhenPingSucceedsButQueryFails_ShouldReturnUnhealthyStatus() throws Exception { - // Given - when(solrClient.ping("test_collection")).thenReturn(pingResponse); - when(solrClient.query(eq("test_collection"), any())) - .thenThrow(new IOException("Query failed")); - - // When - SolrHealthStatus result = collectionService.checkHealth("test_collection"); - - // Then - assertNotNull(result); - assertFalse(result.isHealthy()); - assertNotNull(result.errorMessage()); - assertTrue(result.errorMessage().contains("Query failed")); - } - - @Test - void checkHealth_WithEmptyCollection_ShouldReturnHealthyWithZeroDocuments() throws Exception { - // Given - SolrDocumentList emptyDocList = new SolrDocumentList(); - emptyDocList.setNumFound(0); - - when(solrClient.ping("empty_collection")).thenReturn(pingResponse); - when(pingResponse.getElapsedTime()).thenReturn(5L); - when(solrClient.query(eq("empty_collection"), any())).thenReturn(queryResponse); - when(queryResponse.getResults()).thenReturn(emptyDocList); - - // When - SolrHealthStatus result = collectionService.checkHealth("empty_collection"); - - // Then - assertNotNull(result); - assertTrue(result.isHealthy()); - assertEquals(0L, result.totalDocuments()); - assertEquals(5, result.responseTime()); - } - - @Test - void checkHealth_WithSlowResponse_ShouldCaptureResponseTime() throws Exception { - // Given - SolrDocumentList docList = new SolrDocumentList(); - docList.setNumFound(1000); - - when(solrClient.ping("slow_collection")).thenReturn(pingResponse); - when(pingResponse.getElapsedTime()).thenReturn(5000L); // 5 seconds - when(solrClient.query(eq("slow_collection"), any())).thenReturn(queryResponse); - when(queryResponse.getResults()).thenReturn(docList); - - // When - SolrHealthStatus result = collectionService.checkHealth("slow_collection"); - - // Then - assertNotNull(result); - assertTrue(result.isHealthy()); - assertEquals(5000, result.responseTime()); - assertTrue(result.responseTime() > 1000, "Should capture slow response time"); - } - - @Test - void checkHealth_IOException() throws Exception { - when(solrClient.ping("error_collection")).thenThrow(new IOException("Network error")); - - SolrHealthStatus result = collectionService.checkHealth("error_collection"); - - assertFalse(result.isHealthy()); - assertTrue(result.errorMessage().contains("Network error")); - assertNull(result.responseTime()); - } - - // Query stats tests - @Test - void buildQueryStats_WithValidResponse_ShouldExtractStats() { - // Given - SolrDocumentList docList = new SolrDocumentList(); - docList.setNumFound(250); - docList.setStart(0); - docList.setMaxScore(1.5f); - - when(queryResponse.getQTime()).thenReturn(25); - when(queryResponse.getResults()).thenReturn(docList); - - // When - QueryStats result = collectionService.buildQueryStats(queryResponse); - - // Then - assertNotNull(result); - assertEquals(25, result.queryTime()); - assertEquals(250, result.totalResults()); - assertEquals(0, result.start()); - assertEquals(1.5f, result.maxScore()); - } - - @Test - void buildQueryStats_WithNullMaxScore_ShouldHandleGracefully() { - // Given - SolrDocumentList docList = new SolrDocumentList(); - docList.setNumFound(100); - docList.setStart(10); - docList.setMaxScore(null); - - when(queryResponse.getQTime()).thenReturn(15); - when(queryResponse.getResults()).thenReturn(docList); - - // When - QueryStats result = collectionService.buildQueryStats(queryResponse); - - // Then - assertNotNull(result); - assertEquals(15, result.queryTime()); - assertEquals(100, result.totalResults()); - assertEquals(10, result.start()); - assertNull(result.maxScore()); - } - - // Index stats tests - @Test - void buildIndexStats_ShouldExtractStats() { - NamedList indexInfo = new NamedList<>(); - indexInfo.add("segmentCount", 5); - when(lukeResponse.getIndexInfo()).thenReturn(indexInfo); - when(lukeResponse.getNumDocs()).thenReturn(1000); - - IndexStats result = collectionService.buildIndexStats(lukeResponse); - - assertEquals(1000, result.numDocs()); - assertEquals(5, result.segmentCount()); - } - - @Test - void buildIndexStats_WithNullSegmentCount() { - NamedList indexInfo = new NamedList<>(); - when(lukeResponse.getIndexInfo()).thenReturn(indexInfo); - when(lukeResponse.getNumDocs()).thenReturn(1000); - - IndexStats result = collectionService.buildIndexStats(lukeResponse); - - assertEquals(1000, result.numDocs()); - assertNull(result.segmentCount()); - } - - // Collection validation tests - @Test - void getCollectionStats_NotFound() { - CollectionService spyService = spy(collectionService); - doReturn(Collections.emptyList()).when(spyService).listCollections(); - - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> spyService.getCollectionStats("non_existent")); - - assertTrue(exception.getMessage().contains("Collection not found: non_existent")); - } - - @Test - void validateCollectionExists() throws Exception { - CollectionService spyService = spy(collectionService); - List collections = Arrays.asList("collection1", "films_shard1_replica_n1"); - doReturn(collections).when(spyService).listCollections(); - - Method method = - CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); - method.setAccessible(true); - - assertTrue((boolean) method.invoke(spyService, "collection1")); - assertTrue((boolean) method.invoke(spyService, "films")); - assertFalse((boolean) method.invoke(spyService, "non_existent")); - } - - @Test - void validateCollectionExists_WithException() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Collections.emptyList()).when(spyService).listCollections(); - - Method method = - CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); - method.setAccessible(true); + Method method = CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); + method.setAccessible(true); - assertFalse((boolean) method.invoke(spyService, "any_collection")); - } - - // Cache metrics tests - @Test - void getCacheMetrics_WithNonExistentCollection_ShouldReturnNull() { - // When - Mock will not have collection configured - CacheStats result = collectionService.getCacheMetrics("nonexistent"); - - // Then - assertNull(result); - } + assertFalse((boolean) method.invoke(spyService, "any_collection")); + } - @Test - void getCacheMetrics_Success() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + // Cache metrics tests + @Test + void getCacheMetrics_WithNonExistentCollection_ShouldReturnNull() { + // When - Mock will not have collection configured + CacheStats result = collectionService.getCacheMetrics("nonexistent"); - NamedList mbeans = createMockCacheData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + // Then + assertNull(result); + } - CacheStats result = spyService.getCacheMetrics("test_collection"); + @Test + void getCacheMetrics_Success() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - assertNotNull(result); - assertNotNull(result.queryResultCache()); - assertEquals(100L, result.queryResultCache().lookups()); - } + NamedList mbeans = createMockCacheData(); + when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); - @Test - void getCacheMetrics_CollectionNotFound() { - CollectionService spyService = spy(collectionService); - doReturn(Collections.emptyList()).when(spyService).listCollections(); + CacheStats result = spyService.getCacheMetrics("test_collection"); - CacheStats result = spyService.getCacheMetrics("non_existent"); + assertNotNull(result); + assertNotNull(result.queryResultCache()); + assertEquals(100L, result.queryResultCache().lookups()); + } - assertNull(result); - } + @Test + void getCacheMetrics_CollectionNotFound() { + CollectionService spyService = spy(collectionService); + doReturn(Collections.emptyList()).when(spyService).listCollections(); - @Test - void getCacheMetrics_SolrServerException() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + CacheStats result = spyService.getCacheMetrics("non_existent"); - when(solrClient.request(any(SolrRequest.class))) - .thenThrow(new SolrServerException("Error")); + assertNull(result); + } - CacheStats result = spyService.getCacheMetrics("test_collection"); + @Test + void getCacheMetrics_SolrServerException() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - assertNull(result); - } + when(solrClient.request(any(SolrRequest.class))).thenThrow(new SolrServerException("Error")); - @Test - void getCacheMetrics_IOException() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + CacheStats result = spyService.getCacheMetrics("test_collection"); - when(solrClient.request(any(SolrRequest.class))).thenThrow(new IOException("IO Error")); + assertNull(result); + } - CacheStats result = spyService.getCacheMetrics("test_collection"); + @Test + void getCacheMetrics_IOException() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - assertNull(result); - } - - @Test - void getCacheMetrics_EmptyStats() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + when(solrClient.request(any(SolrRequest.class))).thenThrow(new IOException("IO Error")); - NamedList mbeans = new NamedList<>(); - mbeans.add("CACHE", new NamedList<>()); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + CacheStats result = spyService.getCacheMetrics("test_collection"); - CacheStats result = spyService.getCacheMetrics("test_collection"); + assertNull(result); + } - assertNull(result); - } + @Test + void getCacheMetrics_EmptyStats() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - @Test - void getCacheMetrics_WithShardName() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections(); + NamedList mbeans = new NamedList<>(); + mbeans.add("CACHE", new NamedList<>()); + when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); - NamedList mbeans = createMockCacheData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + CacheStats result = spyService.getCacheMetrics("test_collection"); - CacheStats result = spyService.getCacheMetrics("films_shard1_replica_n1"); + assertNull(result); + } - assertNotNull(result); - } + @Test + void getCacheMetrics_WithShardName() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections(); - @Test - void extractCacheStats() throws Exception { - NamedList mbeans = createMockCacheData(); - Method method = - CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); - method.setAccessible(true); + NamedList mbeans = createMockCacheData(); + when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); - CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); + CacheStats result = spyService.getCacheMetrics("films_shard1_replica_n1"); - assertNotNull(result.queryResultCache()); - assertEquals(100L, result.queryResultCache().lookups()); - assertEquals(80L, result.queryResultCache().hits()); - } + assertNotNull(result); + } - @Test - void extractCacheStats_AllCacheTypes() throws Exception { - NamedList mbeans = createCompleteMockCacheData(); - Method method = - CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); - method.setAccessible(true); + @Test + void extractCacheStats() throws Exception { + NamedList mbeans = createMockCacheData(); + Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); + method.setAccessible(true); - CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); + CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); - assertNotNull(result.queryResultCache()); - assertNotNull(result.documentCache()); - assertNotNull(result.filterCache()); - } + assertNotNull(result.queryResultCache()); + assertEquals(100L, result.queryResultCache().lookups()); + assertEquals(80L, result.queryResultCache().hits()); + } - @Test - void extractCacheStats_NullCacheCategory() throws Exception { - NamedList mbeans = new NamedList<>(); - mbeans.add("CACHE", null); + @Test + void extractCacheStats_AllCacheTypes() throws Exception { + NamedList mbeans = createCompleteMockCacheData(); + Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); + method.setAccessible(true); - Method method = - CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); - method.setAccessible(true); + CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); - CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); + assertNotNull(result.queryResultCache()); + assertNotNull(result.documentCache()); + assertNotNull(result.filterCache()); + } - assertNotNull(result); - assertNull(result.queryResultCache()); - assertNull(result.documentCache()); - assertNull(result.filterCache()); - } + @Test + void extractCacheStats_NullCacheCategory() throws Exception { + NamedList mbeans = new NamedList<>(); + mbeans.add("CACHE", null); - @Test - void isCacheStatsEmpty() throws Exception { - Method method = - CollectionService.class.getDeclaredMethod("isCacheStatsEmpty", CacheStats.class); - method.setAccessible(true); + Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); + method.setAccessible(true); - CacheStats emptyStats = new CacheStats(null, null, null); - assertTrue((boolean) method.invoke(collectionService, emptyStats)); - assertTrue((boolean) method.invoke(collectionService, (CacheStats) null)); + CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); - CacheStats nonEmptyStats = - new CacheStats(new CacheInfo(100L, null, null, null, null, null), null, null); - assertFalse((boolean) method.invoke(collectionService, nonEmptyStats)); - } + assertNotNull(result); + assertNull(result.queryResultCache()); + assertNull(result.documentCache()); + assertNull(result.filterCache()); + } - // Handler metrics tests - @Test - void getHandlerMetrics_WithNonExistentCollection_ShouldReturnNull() { - // When - Mock will not have collection configured - HandlerStats result = collectionService.getHandlerMetrics("nonexistent"); + @Test + void isCacheStatsEmpty() throws Exception { + Method method = CollectionService.class.getDeclaredMethod("isCacheStatsEmpty", CacheStats.class); + method.setAccessible(true); - // Then - assertNull(result); - } + CacheStats emptyStats = new CacheStats(null, null, null); + assertTrue((boolean) method.invoke(collectionService, emptyStats)); + assertTrue((boolean) method.invoke(collectionService, (CacheStats) null)); - @Test - void getHandlerMetrics_Success() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + CacheStats nonEmptyStats = new CacheStats(new CacheInfo(100L, null, null, null, null, null), null, null); + assertFalse((boolean) method.invoke(collectionService, nonEmptyStats)); + } - NamedList mbeans = createMockHandlerData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + // Handler metrics tests + @Test + void getHandlerMetrics_WithNonExistentCollection_ShouldReturnNull() { + // When - Mock will not have collection configured + HandlerStats result = collectionService.getHandlerMetrics("nonexistent"); - HandlerStats result = spyService.getHandlerMetrics("test_collection"); + // Then + assertNull(result); + } - assertNotNull(result); - assertNotNull(result.selectHandler()); - } + @Test + void getHandlerMetrics_Success() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - @Test - void getHandlerMetrics_CollectionNotFound() { - CollectionService spyService = spy(collectionService); - doReturn(Collections.emptyList()).when(spyService).listCollections(); + NamedList mbeans = createMockHandlerData(); + when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); - HandlerStats result = spyService.getHandlerMetrics("non_existent"); + HandlerStats result = spyService.getHandlerMetrics("test_collection"); - assertNull(result); - } + assertNotNull(result); + assertNotNull(result.selectHandler()); + } - @Test - void getHandlerMetrics_SolrServerException() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + @Test + void getHandlerMetrics_CollectionNotFound() { + CollectionService spyService = spy(collectionService); + doReturn(Collections.emptyList()).when(spyService).listCollections(); - when(solrClient.request(any(SolrRequest.class))) - .thenThrow(new SolrServerException("Error")); + HandlerStats result = spyService.getHandlerMetrics("non_existent"); - HandlerStats result = spyService.getHandlerMetrics("test_collection"); + assertNull(result); + } - assertNull(result); - } + @Test + void getHandlerMetrics_SolrServerException() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - @Test - void getHandlerMetrics_IOException() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + when(solrClient.request(any(SolrRequest.class))).thenThrow(new SolrServerException("Error")); - when(solrClient.request(any(SolrRequest.class))).thenThrow(new IOException("IO Error")); + HandlerStats result = spyService.getHandlerMetrics("test_collection"); - HandlerStats result = spyService.getHandlerMetrics("test_collection"); + assertNull(result); + } - assertNull(result); - } + @Test + void getHandlerMetrics_IOException() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - @Test - void getHandlerMetrics_EmptyStats() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); + when(solrClient.request(any(SolrRequest.class))).thenThrow(new IOException("IO Error")); - NamedList mbeans = new NamedList<>(); - mbeans.add("QUERYHANDLER", new NamedList<>()); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + HandlerStats result = spyService.getHandlerMetrics("test_collection"); - HandlerStats result = spyService.getHandlerMetrics("test_collection"); + assertNull(result); + } - assertNull(result); - } + @Test + void getHandlerMetrics_EmptyStats() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - @Test - void getHandlerMetrics_WithShardName() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections(); + NamedList mbeans = new NamedList<>(); + mbeans.add("QUERYHANDLER", new NamedList<>()); + when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); - NamedList mbeans = createMockHandlerData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + HandlerStats result = spyService.getHandlerMetrics("test_collection"); - HandlerStats result = spyService.getHandlerMetrics("films_shard1_replica_n1"); + assertNull(result); + } - assertNotNull(result); - } + @Test + void getHandlerMetrics_WithShardName() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections(); - @Test - void extractHandlerStats() throws Exception { - NamedList mbeans = createMockHandlerData(); - Method method = - CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); - method.setAccessible(true); + NamedList mbeans = createMockHandlerData(); + when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); - HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); + HandlerStats result = spyService.getHandlerMetrics("films_shard1_replica_n1"); - assertNotNull(result.selectHandler()); - assertEquals(500L, result.selectHandler().requests()); - } + assertNotNull(result); + } - @Test - void extractHandlerStats_BothHandlers() throws Exception { - NamedList mbeans = createCompleteHandlerData(); - Method method = - CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); - method.setAccessible(true); + @Test + void extractHandlerStats() throws Exception { + NamedList mbeans = createMockHandlerData(); + Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + method.setAccessible(true); - HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); + HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); - assertNotNull(result.selectHandler()); - assertNotNull(result.updateHandler()); - assertEquals(500L, result.selectHandler().requests()); - assertEquals(250L, result.updateHandler().requests()); - } + assertNotNull(result.selectHandler()); + assertEquals(500L, result.selectHandler().requests()); + } - @Test - void extractHandlerStats_NullHandlerCategory() throws Exception { - NamedList mbeans = new NamedList<>(); - mbeans.add("QUERYHANDLER", null); + @Test + void extractHandlerStats_BothHandlers() throws Exception { + NamedList mbeans = createCompleteHandlerData(); + Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + method.setAccessible(true); - Method method = - CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); - method.setAccessible(true); + HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); - HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); + assertNotNull(result.selectHandler()); + assertNotNull(result.updateHandler()); + assertEquals(500L, result.selectHandler().requests()); + assertEquals(250L, result.updateHandler().requests()); + } - assertNotNull(result); - assertNull(result.selectHandler()); - assertNull(result.updateHandler()); - } + @Test + void extractHandlerStats_NullHandlerCategory() throws Exception { + NamedList mbeans = new NamedList<>(); + mbeans.add("QUERYHANDLER", null); - @Test - void isHandlerStatsEmpty() throws Exception { - Method method = - CollectionService.class.getDeclaredMethod( - "isHandlerStatsEmpty", HandlerStats.class); - method.setAccessible(true); + Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + method.setAccessible(true); - HandlerStats emptyStats = new HandlerStats(null, null); - assertTrue((boolean) method.invoke(collectionService, emptyStats)); - assertTrue((boolean) method.invoke(collectionService, (HandlerStats) null)); + HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); - HandlerStats nonEmptyStats = - new HandlerStats(new HandlerInfo(100L, null, null, null, null, null), null); - assertFalse((boolean) method.invoke(collectionService, nonEmptyStats)); - } - - // List collections tests - @Test - void listCollections_CloudClient_Success() throws Exception { - CloudSolrClient cloudClient = mock(CloudSolrClient.class); + assertNotNull(result); + assertNull(result.selectHandler()); + assertNull(result.updateHandler()); + } - NamedList response = new NamedList<>(); - response.add("collections", Arrays.asList("collection1", "collection2")); + @Test + void isHandlerStatsEmpty() throws Exception { + Method method = CollectionService.class.getDeclaredMethod("isHandlerStatsEmpty", HandlerStats.class); + method.setAccessible(true); - when(cloudClient.request(any(), any())).thenReturn(response); - - CollectionService service = new CollectionService(cloudClient); - List result = service.listCollections(); - - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.contains("collection1")); - assertTrue(result.contains("collection2")); - } - - @Test - void listCollections_CloudClient_NullCollections() throws Exception { - CloudSolrClient cloudClient = mock(CloudSolrClient.class); - - NamedList response = new NamedList<>(); - response.add("collections", null); - - when(cloudClient.request(any(), any())).thenReturn(response); - - CollectionService service = new CollectionService(cloudClient); - List result = service.listCollections(); - - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void listCollections_CloudClient_Error() throws Exception { - CloudSolrClient cloudClient = mock(CloudSolrClient.class); - when(cloudClient.request(any(), any())) - .thenThrow(new SolrServerException("Connection error")); - - CollectionService service = new CollectionService(cloudClient); - List result = service.listCollections(); - - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void listCollections_NonCloudClient_Success() throws Exception { - // Create a NamedList to represent the core status response - NamedList response = new NamedList<>(); - NamedList status = new NamedList<>(); - - NamedList core1Status = new NamedList<>(); - NamedList core2Status = new NamedList<>(); - - status.add("core1", core1Status); - status.add("core2", core2Status); - response.add("status", status); - - // Mock the solrClient request to return the response - when(solrClient.request(any(), any())).thenReturn(response); - - CollectionService service = new CollectionService(solrClient); - List result = service.listCollections(); - - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.contains("core1")); - assertTrue(result.contains("core2")); - } - - @Test - void listCollections_NonCloudClient_Error() throws Exception { - when(solrClient.request(any(), any())).thenThrow(new IOException("IO error")); - - CollectionService service = new CollectionService(solrClient); - List result = service.listCollections(); - - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - // Helper methods - private NamedList createMockCacheData() { - NamedList mbeans = new NamedList<>(); - NamedList cacheCategory = new NamedList<>(); - NamedList queryResultCache = new NamedList<>(); - NamedList queryStats = new NamedList<>(); - - queryStats.add("lookups", 100L); - queryStats.add("hits", 80L); - queryStats.add("hitratio", 0.8f); - queryStats.add("inserts", 20L); - queryStats.add("evictions", 5L); - queryStats.add("size", 100L); - queryResultCache.add("stats", queryStats); - cacheCategory.add("queryResultCache", queryResultCache); - mbeans.add("CACHE", cacheCategory); - - return mbeans; - } - - private NamedList createCompleteMockCacheData() { - NamedList mbeans = new NamedList<>(); - NamedList cacheCategory = new NamedList<>(); - - // Query Result Cache - NamedList queryResultCache = new NamedList<>(); - NamedList queryStats = new NamedList<>(); - queryStats.add("lookups", 100L); - queryStats.add("hits", 80L); - queryStats.add("hitratio", 0.8f); - queryStats.add("inserts", 20L); - queryStats.add("evictions", 5L); - queryStats.add("size", 100L); - queryResultCache.add("stats", queryStats); - - // Document Cache - NamedList documentCache = new NamedList<>(); - NamedList docStats = new NamedList<>(); - docStats.add("lookups", 200L); - docStats.add("hits", 150L); - docStats.add("hitratio", 0.75f); - docStats.add("inserts", 50L); - docStats.add("evictions", 10L); - docStats.add("size", 180L); - documentCache.add("stats", docStats); - - // Filter Cache - NamedList filterCache = new NamedList<>(); - NamedList filterStats = new NamedList<>(); - filterStats.add("lookups", 150L); - filterStats.add("hits", 120L); - filterStats.add("hitratio", 0.8f); - filterStats.add("inserts", 30L); - filterStats.add("evictions", 8L); - filterStats.add("size", 140L); - filterCache.add("stats", filterStats); - - cacheCategory.add("queryResultCache", queryResultCache); - cacheCategory.add("documentCache", documentCache); - cacheCategory.add("filterCache", filterCache); - mbeans.add("CACHE", cacheCategory); - - return mbeans; - } - - private NamedList createMockHandlerData() { - NamedList mbeans = new NamedList<>(); - NamedList queryHandlerCategory = new NamedList<>(); - NamedList selectHandler = new NamedList<>(); - NamedList selectStats = new NamedList<>(); - - selectStats.add("requests", 500L); - selectStats.add("errors", 5L); - selectStats.add("timeouts", 2L); - selectStats.add("totalTime", 10000L); - selectStats.add("avgTimePerRequest", 20.0f); - selectStats.add("avgRequestsPerSecond", 25.0f); - selectHandler.add("stats", selectStats); - queryHandlerCategory.add("/select", selectHandler); - mbeans.add("QUERYHANDLER", queryHandlerCategory); - - return mbeans; - } - - private NamedList createCompleteHandlerData() { - NamedList mbeans = new NamedList<>(); - NamedList queryHandlerCategory = new NamedList<>(); - - // Select Handler - NamedList selectHandler = new NamedList<>(); - NamedList selectStats = new NamedList<>(); - selectStats.add("requests", 500L); - selectStats.add("errors", 5L); - selectStats.add("timeouts", 2L); - selectStats.add("totalTime", 10000L); - selectStats.add("avgTimePerRequest", 20.0f); - selectStats.add("avgRequestsPerSecond", 25.0f); - selectHandler.add("stats", selectStats); - - // Update Handler - NamedList updateHandler = new NamedList<>(); - NamedList updateStats = new NamedList<>(); - updateStats.add("requests", 250L); - updateStats.add("errors", 2L); - updateStats.add("timeouts", 1L); - updateStats.add("totalTime", 5000L); - updateStats.add("avgTimePerRequest", 20.0f); - updateStats.add("avgRequestsPerSecond", 50.0f); - updateHandler.add("stats", updateStats); - - queryHandlerCategory.add("/select", selectHandler); - queryHandlerCategory.add("/update", updateHandler); - mbeans.add("QUERYHANDLER", queryHandlerCategory); - - return mbeans; - } + HandlerStats emptyStats = new HandlerStats(null, null); + assertTrue((boolean) method.invoke(collectionService, emptyStats)); + assertTrue((boolean) method.invoke(collectionService, (HandlerStats) null)); + + HandlerStats nonEmptyStats = new HandlerStats(new HandlerInfo(100L, null, null, null, null, null), null); + assertFalse((boolean) method.invoke(collectionService, nonEmptyStats)); + } + + // List collections tests + @Test + void listCollections_CloudClient_Success() throws Exception { + CloudSolrClient cloudClient = mock(CloudSolrClient.class); + + NamedList response = new NamedList<>(); + response.add("collections", Arrays.asList("collection1", "collection2")); + + when(cloudClient.request(any(), any())).thenReturn(response); + + CollectionService service = new CollectionService(cloudClient); + List result = service.listCollections(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains("collection1")); + assertTrue(result.contains("collection2")); + } + + @Test + void listCollections_CloudClient_NullCollections() throws Exception { + CloudSolrClient cloudClient = mock(CloudSolrClient.class); + + NamedList response = new NamedList<>(); + response.add("collections", null); + + when(cloudClient.request(any(), any())).thenReturn(response); + + CollectionService service = new CollectionService(cloudClient); + List result = service.listCollections(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void listCollections_CloudClient_Error() throws Exception { + CloudSolrClient cloudClient = mock(CloudSolrClient.class); + when(cloudClient.request(any(), any())).thenThrow(new SolrServerException("Connection error")); + + CollectionService service = new CollectionService(cloudClient); + List result = service.listCollections(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void listCollections_NonCloudClient_Success() throws Exception { + // Create a NamedList to represent the core status response + NamedList response = new NamedList<>(); + NamedList status = new NamedList<>(); + + NamedList core1Status = new NamedList<>(); + NamedList core2Status = new NamedList<>(); + + status.add("core1", core1Status); + status.add("core2", core2Status); + response.add("status", status); + + // Mock the solrClient request to return the response + when(solrClient.request(any(), any())).thenReturn(response); + + CollectionService service = new CollectionService(solrClient); + List result = service.listCollections(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains("core1")); + assertTrue(result.contains("core2")); + } + + @Test + void listCollections_NonCloudClient_Error() throws Exception { + when(solrClient.request(any(), any())).thenThrow(new IOException("IO error")); + + CollectionService service = new CollectionService(solrClient); + List result = service.listCollections(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + // Helper methods + private NamedList createMockCacheData() { + NamedList mbeans = new NamedList<>(); + NamedList cacheCategory = new NamedList<>(); + NamedList queryResultCache = new NamedList<>(); + NamedList queryStats = new NamedList<>(); + + queryStats.add("lookups", 100L); + queryStats.add("hits", 80L); + queryStats.add("hitratio", 0.8f); + queryStats.add("inserts", 20L); + queryStats.add("evictions", 5L); + queryStats.add("size", 100L); + queryResultCache.add("stats", queryStats); + cacheCategory.add("queryResultCache", queryResultCache); + mbeans.add("CACHE", cacheCategory); + + return mbeans; + } + + private NamedList createCompleteMockCacheData() { + NamedList mbeans = new NamedList<>(); + NamedList cacheCategory = new NamedList<>(); + + // Query Result Cache + NamedList queryResultCache = new NamedList<>(); + NamedList queryStats = new NamedList<>(); + queryStats.add("lookups", 100L); + queryStats.add("hits", 80L); + queryStats.add("hitratio", 0.8f); + queryStats.add("inserts", 20L); + queryStats.add("evictions", 5L); + queryStats.add("size", 100L); + queryResultCache.add("stats", queryStats); + + // Document Cache + NamedList documentCache = new NamedList<>(); + NamedList docStats = new NamedList<>(); + docStats.add("lookups", 200L); + docStats.add("hits", 150L); + docStats.add("hitratio", 0.75f); + docStats.add("inserts", 50L); + docStats.add("evictions", 10L); + docStats.add("size", 180L); + documentCache.add("stats", docStats); + + // Filter Cache + NamedList filterCache = new NamedList<>(); + NamedList filterStats = new NamedList<>(); + filterStats.add("lookups", 150L); + filterStats.add("hits", 120L); + filterStats.add("hitratio", 0.8f); + filterStats.add("inserts", 30L); + filterStats.add("evictions", 8L); + filterStats.add("size", 140L); + filterCache.add("stats", filterStats); + + cacheCategory.add("queryResultCache", queryResultCache); + cacheCategory.add("documentCache", documentCache); + cacheCategory.add("filterCache", filterCache); + mbeans.add("CACHE", cacheCategory); + + return mbeans; + } + + private NamedList createMockHandlerData() { + NamedList mbeans = new NamedList<>(); + NamedList queryHandlerCategory = new NamedList<>(); + NamedList selectHandler = new NamedList<>(); + NamedList selectStats = new NamedList<>(); + + selectStats.add("requests", 500L); + selectStats.add("errors", 5L); + selectStats.add("timeouts", 2L); + selectStats.add("totalTime", 10000L); + selectStats.add("avgTimePerRequest", 20.0f); + selectStats.add("avgRequestsPerSecond", 25.0f); + selectHandler.add("stats", selectStats); + queryHandlerCategory.add("/select", selectHandler); + mbeans.add("QUERYHANDLER", queryHandlerCategory); + + return mbeans; + } + + private NamedList createCompleteHandlerData() { + NamedList mbeans = new NamedList<>(); + NamedList queryHandlerCategory = new NamedList<>(); + + // Select Handler + NamedList selectHandler = new NamedList<>(); + NamedList selectStats = new NamedList<>(); + selectStats.add("requests", 500L); + selectStats.add("errors", 5L); + selectStats.add("timeouts", 2L); + selectStats.add("totalTime", 10000L); + selectStats.add("avgTimePerRequest", 20.0f); + selectStats.add("avgRequestsPerSecond", 25.0f); + selectHandler.add("stats", selectStats); + + // Update Handler + NamedList updateHandler = new NamedList<>(); + NamedList updateStats = new NamedList<>(); + updateStats.add("requests", 250L); + updateStats.add("errors", 2L); + updateStats.add("timeouts", 1L); + updateStats.add("totalTime", 5000L); + updateStats.add("avgTimePerRequest", 20.0f); + updateStats.add("avgRequestsPerSecond", 50.0f); + updateHandler.add("stats", updateStats); + + queryHandlerCategory.add("/select", selectHandler); + queryHandlerCategory.add("/update", updateHandler); + mbeans.add("QUERYHANDLER", queryHandlerCategory); + + return mbeans; + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java index 1e0ba34..2365494 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java @@ -25,287 +25,288 @@ import org.junit.jupiter.api.Test; /** - * Comprehensive test suite for the Utils utility class. Tests all public methods and edge cases for - * type-safe value extraction from Solr NamedList objects. + * Comprehensive test suite for the Utils utility class. Tests all public + * methods and edge cases for type-safe value extraction from Solr NamedList + * objects. */ class CollectionUtilsTest { - @Test - void testGetLong_withNullValue() { - NamedList namedList = new NamedList<>(); - namedList.add("nullKey", null); + @Test + void testGetLong_withNullValue() { + NamedList namedList = new NamedList<>(); + namedList.add("nullKey", null); - assertNull(CollectionUtils.getLong(namedList, "nullKey")); - } + assertNull(CollectionUtils.getLong(namedList, "nullKey")); + } - @Test - void testGetLong_withMissingKey() { - NamedList namedList = new NamedList<>(); + @Test + void testGetLong_withMissingKey() { + NamedList namedList = new NamedList<>(); - assertNull(CollectionUtils.getLong(namedList, "missingKey")); - } + assertNull(CollectionUtils.getLong(namedList, "missingKey")); + } - @Test - void testGetLong_withIntegerValue() { - NamedList namedList = new NamedList<>(); - namedList.add("intKey", 123); + @Test + void testGetLong_withIntegerValue() { + NamedList namedList = new NamedList<>(); + namedList.add("intKey", 123); - assertEquals(123L, CollectionUtils.getLong(namedList, "intKey")); - } + assertEquals(123L, CollectionUtils.getLong(namedList, "intKey")); + } - @Test - void testGetLong_withLongValue() { - NamedList namedList = new NamedList<>(); - namedList.add("longKey", 123456789L); + @Test + void testGetLong_withLongValue() { + NamedList namedList = new NamedList<>(); + namedList.add("longKey", 123456789L); - assertEquals(123456789L, CollectionUtils.getLong(namedList, "longKey")); - } + assertEquals(123456789L, CollectionUtils.getLong(namedList, "longKey")); + } - @Test - void testGetLong_withDoubleValue() { - NamedList namedList = new NamedList<>(); - namedList.add("doubleKey", 123.45); + @Test + void testGetLong_withDoubleValue() { + NamedList namedList = new NamedList<>(); + namedList.add("doubleKey", 123.45); - assertEquals(123L, CollectionUtils.getLong(namedList, "doubleKey")); - } + assertEquals(123L, CollectionUtils.getLong(namedList, "doubleKey")); + } - @Test - void testGetLong_withFloatValue() { - NamedList namedList = new NamedList<>(); - namedList.add("floatKey", 123.45f); + @Test + void testGetLong_withFloatValue() { + NamedList namedList = new NamedList<>(); + namedList.add("floatKey", 123.45f); - assertEquals(123L, CollectionUtils.getLong(namedList, "floatKey")); - } + assertEquals(123L, CollectionUtils.getLong(namedList, "floatKey")); + } - @Test - void testGetLong_withBigIntegerValue() { - NamedList namedList = new NamedList<>(); - namedList.add("bigIntKey", new BigInteger("123456789")); + @Test + void testGetLong_withBigIntegerValue() { + NamedList namedList = new NamedList<>(); + namedList.add("bigIntKey", new BigInteger("123456789")); - assertEquals(123456789L, CollectionUtils.getLong(namedList, "bigIntKey")); - } + assertEquals(123456789L, CollectionUtils.getLong(namedList, "bigIntKey")); + } - @Test - void testGetLong_withBigDecimalValue() { - NamedList namedList = new NamedList<>(); - namedList.add("bigDecKey", new BigDecimal("123.45")); + @Test + void testGetLong_withBigDecimalValue() { + NamedList namedList = new NamedList<>(); + namedList.add("bigDecKey", new BigDecimal("123.45")); - assertEquals(123L, CollectionUtils.getLong(namedList, "bigDecKey")); - } + assertEquals(123L, CollectionUtils.getLong(namedList, "bigDecKey")); + } - @Test - void testGetLong_withValidStringValue() { - NamedList namedList = new NamedList<>(); - namedList.add("stringKey", "123456"); + @Test + void testGetLong_withValidStringValue() { + NamedList namedList = new NamedList<>(); + namedList.add("stringKey", "123456"); - assertEquals(123456L, CollectionUtils.getLong(namedList, "stringKey")); - } + assertEquals(123456L, CollectionUtils.getLong(namedList, "stringKey")); + } - @Test - void testGetLong_withInvalidStringValue() { - NamedList namedList = new NamedList<>(); - namedList.add("invalidStringKey", "not_a_number"); + @Test + void testGetLong_withInvalidStringValue() { + NamedList namedList = new NamedList<>(); + namedList.add("invalidStringKey", "not_a_number"); - assertNull(CollectionUtils.getLong(namedList, "invalidStringKey")); - } + assertNull(CollectionUtils.getLong(namedList, "invalidStringKey")); + } - @Test - void testGetLong_withEmptyStringValue() { - NamedList namedList = new NamedList<>(); - namedList.add("emptyStringKey", ""); + @Test + void testGetLong_withEmptyStringValue() { + NamedList namedList = new NamedList<>(); + namedList.add("emptyStringKey", ""); - assertNull(CollectionUtils.getLong(namedList, "emptyStringKey")); - } + assertNull(CollectionUtils.getLong(namedList, "emptyStringKey")); + } - @Test - void testGetFloat_withNullValue() { - NamedList namedList = new NamedList<>(); - namedList.add("nullKey", null); + @Test + void testGetFloat_withNullValue() { + NamedList namedList = new NamedList<>(); + namedList.add("nullKey", null); - assertEquals(0.0f, CollectionUtils.getFloat(namedList, "nullKey")); - } + assertEquals(0.0f, CollectionUtils.getFloat(namedList, "nullKey")); + } - @Test - void testGetFloat_withMissingKey() { - NamedList namedList = new NamedList<>(); + @Test + void testGetFloat_withMissingKey() { + NamedList namedList = new NamedList<>(); - assertEquals(0.0f, CollectionUtils.getFloat(namedList, "missingKey")); - } + assertEquals(0.0f, CollectionUtils.getFloat(namedList, "missingKey")); + } - @Test - void testGetFloat_withIntegerValue() { - NamedList namedList = new NamedList<>(); - namedList.add("intKey", 123); + @Test + void testGetFloat_withIntegerValue() { + NamedList namedList = new NamedList<>(); + namedList.add("intKey", 123); - assertEquals(123.0f, CollectionUtils.getFloat(namedList, "intKey")); - } + assertEquals(123.0f, CollectionUtils.getFloat(namedList, "intKey")); + } - @Test - void testGetFloat_withFloatValue() { - NamedList namedList = new NamedList<>(); - namedList.add("floatKey", 123.45f); + @Test + void testGetFloat_withFloatValue() { + NamedList namedList = new NamedList<>(); + namedList.add("floatKey", 123.45f); - assertEquals(123.45f, CollectionUtils.getFloat(namedList, "floatKey"), 0.001f); - } + assertEquals(123.45f, CollectionUtils.getFloat(namedList, "floatKey"), 0.001f); + } - @Test - void testGetFloat_withDoubleValue() { - NamedList namedList = new NamedList<>(); - namedList.add("doubleKey", 123.456789); + @Test + void testGetFloat_withDoubleValue() { + NamedList namedList = new NamedList<>(); + namedList.add("doubleKey", 123.456789); - assertEquals(123.456789f, CollectionUtils.getFloat(namedList, "doubleKey"), 0.001f); - } + assertEquals(123.456789f, CollectionUtils.getFloat(namedList, "doubleKey"), 0.001f); + } - @Test - void testGetFloat_withLongValue() { - NamedList namedList = new NamedList<>(); - namedList.add("longKey", 123456789L); + @Test + void testGetFloat_withLongValue() { + NamedList namedList = new NamedList<>(); + namedList.add("longKey", 123456789L); - assertEquals(123456789.0f, CollectionUtils.getFloat(namedList, "longKey")); - } + assertEquals(123456789.0f, CollectionUtils.getFloat(namedList, "longKey")); + } - @Test - void testGetFloat_withBigDecimalValue() { - NamedList namedList = new NamedList<>(); - namedList.add("bigDecKey", new BigDecimal("123.45")); + @Test + void testGetFloat_withBigDecimalValue() { + NamedList namedList = new NamedList<>(); + namedList.add("bigDecKey", new BigDecimal("123.45")); - assertEquals(123.45f, CollectionUtils.getFloat(namedList, "bigDecKey"), 0.001f); - } + assertEquals(123.45f, CollectionUtils.getFloat(namedList, "bigDecKey"), 0.001f); + } - @Test - void testGetInteger_withNullValue() { - NamedList namedList = new NamedList<>(); - namedList.add("nullKey", null); + @Test + void testGetInteger_withNullValue() { + NamedList namedList = new NamedList<>(); + namedList.add("nullKey", null); - assertNull(CollectionUtils.getInteger(namedList, "nullKey")); - } + assertNull(CollectionUtils.getInteger(namedList, "nullKey")); + } - @Test - void testGetInteger_withMissingKey() { - NamedList namedList = new NamedList<>(); + @Test + void testGetInteger_withMissingKey() { + NamedList namedList = new NamedList<>(); - assertNull(CollectionUtils.getInteger(namedList, "missingKey")); - } + assertNull(CollectionUtils.getInteger(namedList, "missingKey")); + } - @Test - void testGetInteger_withIntegerValue() { - NamedList namedList = new NamedList<>(); - namedList.add("intKey", 123); + @Test + void testGetInteger_withIntegerValue() { + NamedList namedList = new NamedList<>(); + namedList.add("intKey", 123); - assertEquals(123, CollectionUtils.getInteger(namedList, "intKey")); - } + assertEquals(123, CollectionUtils.getInteger(namedList, "intKey")); + } - @Test - void testGetInteger_withLongValue() { - NamedList namedList = new NamedList<>(); - namedList.add("longKey", 123456L); + @Test + void testGetInteger_withLongValue() { + NamedList namedList = new NamedList<>(); + namedList.add("longKey", 123456L); - assertEquals(123456, CollectionUtils.getInteger(namedList, "longKey")); - } + assertEquals(123456, CollectionUtils.getInteger(namedList, "longKey")); + } - @Test - void testGetInteger_withDoubleValue() { - NamedList namedList = new NamedList<>(); - namedList.add("doubleKey", 123.45); + @Test + void testGetInteger_withDoubleValue() { + NamedList namedList = new NamedList<>(); + namedList.add("doubleKey", 123.45); - assertEquals(123, CollectionUtils.getInteger(namedList, "doubleKey")); - } + assertEquals(123, CollectionUtils.getInteger(namedList, "doubleKey")); + } - @Test - void testGetInteger_withFloatValue() { - NamedList namedList = new NamedList<>(); - namedList.add("floatKey", 123.45f); + @Test + void testGetInteger_withFloatValue() { + NamedList namedList = new NamedList<>(); + namedList.add("floatKey", 123.45f); - assertEquals(123, CollectionUtils.getInteger(namedList, "floatKey")); - } + assertEquals(123, CollectionUtils.getInteger(namedList, "floatKey")); + } - @Test - void testGetInteger_withBigIntegerValue() { - NamedList namedList = new NamedList<>(); - namedList.add("bigIntKey", new BigInteger("123456")); + @Test + void testGetInteger_withBigIntegerValue() { + NamedList namedList = new NamedList<>(); + namedList.add("bigIntKey", new BigInteger("123456")); - assertEquals(123456, CollectionUtils.getInteger(namedList, "bigIntKey")); - } + assertEquals(123456, CollectionUtils.getInteger(namedList, "bigIntKey")); + } - @Test - void testGetInteger_withValidStringValue() { - NamedList namedList = new NamedList<>(); - namedList.add("stringKey", "123456"); + @Test + void testGetInteger_withValidStringValue() { + NamedList namedList = new NamedList<>(); + namedList.add("stringKey", "123456"); - assertEquals(123456, CollectionUtils.getInteger(namedList, "stringKey")); - } + assertEquals(123456, CollectionUtils.getInteger(namedList, "stringKey")); + } - @Test - void testGetInteger_withInvalidStringValue() { - NamedList namedList = new NamedList<>(); - namedList.add("invalidStringKey", "not_a_number"); + @Test + void testGetInteger_withInvalidStringValue() { + NamedList namedList = new NamedList<>(); + namedList.add("invalidStringKey", "not_a_number"); - assertNull(CollectionUtils.getInteger(namedList, "invalidStringKey")); - } + assertNull(CollectionUtils.getInteger(namedList, "invalidStringKey")); + } - @Test - void testGetInteger_withEmptyStringValue() { - NamedList namedList = new NamedList<>(); - namedList.add("emptyStringKey", ""); + @Test + void testGetInteger_withEmptyStringValue() { + NamedList namedList = new NamedList<>(); + namedList.add("emptyStringKey", ""); - assertNull(CollectionUtils.getInteger(namedList, "emptyStringKey")); - } + assertNull(CollectionUtils.getInteger(namedList, "emptyStringKey")); + } - @Test - void testGetInteger_withOverflowValue() { - NamedList namedList = new NamedList<>(); - // Value larger than Integer.MAX_VALUE - namedList.add("overflowKey", Long.MAX_VALUE); - - // Should truncate to int value (overflow behavior) - assertEquals(-1, CollectionUtils.getInteger(namedList, "overflowKey")); - } + @Test + void testGetInteger_withOverflowValue() { + NamedList namedList = new NamedList<>(); + // Value larger than Integer.MAX_VALUE + namedList.add("overflowKey", Long.MAX_VALUE); + + // Should truncate to int value (overflow behavior) + assertEquals(-1, CollectionUtils.getInteger(namedList, "overflowKey")); + } - @Test - void testGetLong_withNegativeValues() { - NamedList namedList = new NamedList<>(); - namedList.add("negativeKey", -123456); + @Test + void testGetLong_withNegativeValues() { + NamedList namedList = new NamedList<>(); + namedList.add("negativeKey", -123456); - assertEquals(-123456L, CollectionUtils.getLong(namedList, "negativeKey")); - } + assertEquals(-123456L, CollectionUtils.getLong(namedList, "negativeKey")); + } - @Test - void testGetFloat_withNegativeValues() { - NamedList namedList = new NamedList<>(); - namedList.add("negativeKey", -123.45f); + @Test + void testGetFloat_withNegativeValues() { + NamedList namedList = new NamedList<>(); + namedList.add("negativeKey", -123.45f); - assertEquals(-123.45f, CollectionUtils.getFloat(namedList, "negativeKey"), 0.001f); - } + assertEquals(-123.45f, CollectionUtils.getFloat(namedList, "negativeKey"), 0.001f); + } - @Test - void testGetInteger_withNegativeValues() { - NamedList namedList = new NamedList<>(); - namedList.add("negativeKey", -123456); + @Test + void testGetInteger_withNegativeValues() { + NamedList namedList = new NamedList<>(); + namedList.add("negativeKey", -123456); - assertEquals(-123456, CollectionUtils.getInteger(namedList, "negativeKey")); - } - - @Test - void testGetLong_withZeroValue() { - NamedList namedList = new NamedList<>(); - namedList.add("zeroKey", 0); + assertEquals(-123456, CollectionUtils.getInteger(namedList, "negativeKey")); + } + + @Test + void testGetLong_withZeroValue() { + NamedList namedList = new NamedList<>(); + namedList.add("zeroKey", 0); - assertEquals(0L, CollectionUtils.getLong(namedList, "zeroKey")); - } - - @Test - void testGetFloat_withZeroValue() { - NamedList namedList = new NamedList<>(); - namedList.add("zeroKey", 0.0f); + assertEquals(0L, CollectionUtils.getLong(namedList, "zeroKey")); + } + + @Test + void testGetFloat_withZeroValue() { + NamedList namedList = new NamedList<>(); + namedList.add("zeroKey", 0.0f); - assertEquals(0.0f, CollectionUtils.getFloat(namedList, "zeroKey")); - } - - @Test - void testGetInteger_withZeroValue() { - NamedList namedList = new NamedList<>(); - namedList.add("zeroKey", 0); + assertEquals(0.0f, CollectionUtils.getFloat(namedList, "zeroKey")); + } + + @Test + void testGetInteger_withZeroValue() { + NamedList namedList = new NamedList<>(); + namedList.add("zeroKey", 0); - assertEquals(0, CollectionUtils.getInteger(namedList, "zeroKey")); - } + assertEquals(0, CollectionUtils.getInteger(namedList, "zeroKey")); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java index e49e3c7..164f92f 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java @@ -29,169 +29,145 @@ import org.springframework.context.annotation.Import; /** - * Integration test suite for SchemaService using real Solr containers. Tests actual schema - * retrieval functionality against a live Solr instance. + * Integration test suite for SchemaService using real Solr containers. Tests + * actual schema retrieval functionality against a live Solr instance. */ @SpringBootTest @Import(TestcontainersConfiguration.class) class SchemaServiceIntegrationTest { - @Autowired private SchemaService schemaService; - - @Autowired private SolrClient solrClient; - - private static final String TEST_COLLECTION = "schema_test_collection"; - private static boolean initialized = false; - - @BeforeEach - void setupCollection() throws Exception { - // Create a collection for testing - if (!initialized) { - CollectionAdminRequest.Create createRequest = - CollectionAdminRequest.createCollection(TEST_COLLECTION, "_default", 1, 1); - createRequest.process(solrClient); - initialized = true; - } - } - - @Test - void testGetSchema_ValidCollection() throws Exception { - // When - SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); - - // Then - assertNotNull(schema, "Schema should not be null"); - assertNotNull(schema.getFields(), "Schema fields should not be null"); - assertNotNull(schema.getFieldTypes(), "Schema field types should not be null"); - - // Verify basic schema properties - assertFalse(schema.getFields().isEmpty(), "Schema should have at least some fields"); - assertFalse( - schema.getFieldTypes().isEmpty(), "Schema should have at least some field types"); - - // Check for common default fields in Solr - boolean hasIdField = - schema.getFields().stream().anyMatch(field -> "id".equals(field.get("name"))); - assertTrue(hasIdField, "Schema should have an 'id' field"); - - // Check for common field types - boolean hasStringType = - schema.getFieldTypes().stream() - .anyMatch( - fieldType -> - "string".equals(fieldType.getAttributes().get("name"))); - assertTrue(hasStringType, "Schema should have a 'string' field type"); - } - - @Test - void testGetSchema_InvalidCollection() { - // When/Then - assertThrows( - Exception.class, - () -> { - schemaService.getSchema("non_existent_collection_12345"); - }, - "Getting schema for non-existent collection should throw exception"); - } - - @Test - void testGetSchema_NullCollection() { - // When/Then - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(null); - }, - "Getting schema with null collection should throw exception"); - } - - @Test - void testGetSchema_EmptyCollection() { - // When/Then - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(""); - }, - "Getting schema with empty collection should throw exception"); - } - - @Test - void testGetSchema_ValidatesSchemaContent() throws Exception { - // When - SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); - - // Then - verify schema has expected structure - assertNotNull(schema.getName(), "Schema should have a name"); - - // Check that we can access field details - schema.getFields() - .forEach( - field -> { - assertNotNull(field.get("name"), "Field should have a name"); - assertNotNull(field.get("type"), "Field should have a type"); - // indexed and stored can be null (defaults to true in many cases) - }); - - // Check that we can access field type details - schema.getFieldTypes() - .forEach( - fieldType -> { - assertNotNull( - fieldType.getAttributes().get("name"), - "Field type should have a name"); - assertNotNull( - fieldType.getAttributes().get("class"), - "Field type should have a class"); - }); - } - - @Test - void testGetSchema_ChecksDynamicFields() throws Exception { - // When - SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); - - // Then - verify dynamic fields are accessible - assertNotNull(schema.getDynamicFields(), "Dynamic fields should not be null"); - - // Most Solr schemas have some dynamic fields by default - assertTrue(schema.getDynamicFields().size() >= 0, "Dynamic fields should be a valid list"); - - // Check for common dynamic field patterns - boolean hasStringDynamicField = - schema.getDynamicFields().stream() - .anyMatch( - dynField -> { - String name = (String) dynField.get("name"); - return name != null - && (name.contains("*_s") || name.contains("*_str")); - }); - - assertTrue(hasStringDynamicField, "Schema should have string dynamic fields"); - } - - @Test - void testGetSchema_ChecksCopyFields() throws Exception { - // When - SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); - - // Then - verify copy fields are accessible - assertNotNull(schema.getCopyFields(), "Copy fields should not be null"); - - // Copy fields list can be empty, that's valid - assertTrue(schema.getCopyFields().size() >= 0, "Copy fields should be a valid list"); - } - - @Test - void testGetSchema_ReturnsUniqueKey() throws Exception { - // When - SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); - - // Then - verify unique key is accessible - // Unique key can be null in some schemas, but the method should work - // Most default schemas have 'id' as unique key - if (schema.getUniqueKey() != null) { - assertNotNull(schema.getUniqueKey(), "Unique key should be accessible"); - } - } + @Autowired + private SchemaService schemaService; + + @Autowired + private SolrClient solrClient; + + private static final String TEST_COLLECTION = "schema_test_collection"; + private static boolean initialized = false; + + @BeforeEach + void setupCollection() throws Exception { + // Create a collection for testing + if (!initialized) { + CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection(TEST_COLLECTION, + "_default", 1, 1); + createRequest.process(solrClient); + initialized = true; + } + } + + @Test + void testGetSchema_ValidCollection() throws Exception { + // When + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + + // Then + assertNotNull(schema, "Schema should not be null"); + assertNotNull(schema.getFields(), "Schema fields should not be null"); + assertNotNull(schema.getFieldTypes(), "Schema field types should not be null"); + + // Verify basic schema properties + assertFalse(schema.getFields().isEmpty(), "Schema should have at least some fields"); + assertFalse(schema.getFieldTypes().isEmpty(), "Schema should have at least some field types"); + + // Check for common default fields in Solr + boolean hasIdField = schema.getFields().stream().anyMatch(field -> "id".equals(field.get("name"))); + assertTrue(hasIdField, "Schema should have an 'id' field"); + + // Check for common field types + boolean hasStringType = schema.getFieldTypes().stream() + .anyMatch(fieldType -> "string".equals(fieldType.getAttributes().get("name"))); + assertTrue(hasStringType, "Schema should have a 'string' field type"); + } + + @Test + void testGetSchema_InvalidCollection() { + // When/Then + assertThrows(Exception.class, () -> { + schemaService.getSchema("non_existent_collection_12345"); + }, "Getting schema for non-existent collection should throw exception"); + } + + @Test + void testGetSchema_NullCollection() { + // When/Then + assertThrows(Exception.class, () -> { + schemaService.getSchema(null); + }, "Getting schema with null collection should throw exception"); + } + + @Test + void testGetSchema_EmptyCollection() { + // When/Then + assertThrows(Exception.class, () -> { + schemaService.getSchema(""); + }, "Getting schema with empty collection should throw exception"); + } + + @Test + void testGetSchema_ValidatesSchemaContent() throws Exception { + // When + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + + // Then - verify schema has expected structure + assertNotNull(schema.getName(), "Schema should have a name"); + + // Check that we can access field details + schema.getFields().forEach(field -> { + assertNotNull(field.get("name"), "Field should have a name"); + assertNotNull(field.get("type"), "Field should have a type"); + // indexed and stored can be null (defaults to true in many cases) + }); + + // Check that we can access field type details + schema.getFieldTypes().forEach(fieldType -> { + assertNotNull(fieldType.getAttributes().get("name"), "Field type should have a name"); + assertNotNull(fieldType.getAttributes().get("class"), "Field type should have a class"); + }); + } + + @Test + void testGetSchema_ChecksDynamicFields() throws Exception { + // When + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + + // Then - verify dynamic fields are accessible + assertNotNull(schema.getDynamicFields(), "Dynamic fields should not be null"); + + // Most Solr schemas have some dynamic fields by default + assertTrue(schema.getDynamicFields().size() >= 0, "Dynamic fields should be a valid list"); + + // Check for common dynamic field patterns + boolean hasStringDynamicField = schema.getDynamicFields().stream().anyMatch(dynField -> { + String name = (String) dynField.get("name"); + return name != null && (name.contains("*_s") || name.contains("*_str")); + }); + + assertTrue(hasStringDynamicField, "Schema should have string dynamic fields"); + } + + @Test + void testGetSchema_ChecksCopyFields() throws Exception { + // When + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + + // Then - verify copy fields are accessible + assertNotNull(schema.getCopyFields(), "Copy fields should not be null"); + + // Copy fields list can be empty, that's valid + assertTrue(schema.getCopyFields().size() >= 0, "Copy fields should be a valid list"); + } + + @Test + void testGetSchema_ReturnsUniqueKey() throws Exception { + // When + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + + // Then - verify unique key is accessible + // Unique key can be null in some schemas, but the method should work + // Most default schemas have 'id' as unique key + if (schema.getUniqueKey() != null) { + assertNotNull(schema.getUniqueKey(), "Unique key should be accessible"); + } + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java index 803e94e..56321b8 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java @@ -34,121 +34,113 @@ import org.mockito.junit.jupiter.MockitoExtension; /** - * Comprehensive test suite for the SchemaService class. Tests schema retrieval functionality with - * various scenarios including success and error cases. + * Comprehensive test suite for the SchemaService class. Tests schema retrieval + * functionality with various scenarios including success and error cases. */ @ExtendWith(MockitoExtension.class) class SchemaServiceTest { - @Mock private SolrClient solrClient; - - @Mock private SchemaResponse schemaResponse; - - @Mock private SchemaRepresentation schemaRepresentation; - - private SchemaService schemaService; - - @BeforeEach - void setUp() { - schemaService = new SchemaService(solrClient); - } - - @Test - void testSchemaService_InstantiatesCorrectly() { - // Given/When - SchemaService service = new SchemaService(solrClient); - - // Then - assertNotNull(service, "SchemaService should be instantiated correctly"); - } - - @Test - void testGetSchema_CollectionNotFound() throws Exception { - // Given - final String nonExistentCollection = "non_existent_collection"; - - // When SolrClient throws an exception for non-existent collection - when(solrClient.request(any(SchemaRequest.class), eq(nonExistentCollection))) - .thenThrow( - new SolrServerException("Collection not found: " + nonExistentCollection)); - - // Then - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(nonExistentCollection); - }); - } - - @Test - void testGetSchema_SolrServerException() throws Exception { - // Given - final String collectionName = "test_collection"; - - // When SolrClient throws a SolrServerException - when(solrClient.request(any(SchemaRequest.class), eq(collectionName))) - .thenThrow(new SolrServerException("Solr server error")); - - // Then - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(collectionName); - }); - } - - @Test - void testGetSchema_IOException() throws Exception { - // Given - final String collectionName = "test_collection"; - - // When SolrClient throws an IOException - when(solrClient.request(any(SchemaRequest.class), eq(collectionName))) - .thenThrow(new IOException("Network connection error")); - - // Then - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(collectionName); - }); - } - - @Test - void testGetSchema_WithNullCollection() { - // Given a null collection name - // Then should throw an exception (NullPointerException or IllegalArgumentException) - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(null); - }); - } - - @Test - void testGetSchema_WithEmptyCollection() { - // Given an empty collection name - // Then should throw an exception - assertThrows( - Exception.class, - () -> { - schemaService.getSchema(""); - }); - } - - @Test - void testConstructor() { - // Test that constructor properly initializes the service - SchemaService service = new SchemaService(solrClient); - assertNotNull(service); - } - - @Test - void testConstructor_WithNullClient() { - // Test constructor with null client - assertDoesNotThrow( - () -> { - new SchemaService(null); - }); - } + @Mock + private SolrClient solrClient; + + @Mock + private SchemaResponse schemaResponse; + + @Mock + private SchemaRepresentation schemaRepresentation; + + private SchemaService schemaService; + + @BeforeEach + void setUp() { + schemaService = new SchemaService(solrClient); + } + + @Test + void testSchemaService_InstantiatesCorrectly() { + // Given/When + SchemaService service = new SchemaService(solrClient); + + // Then + assertNotNull(service, "SchemaService should be instantiated correctly"); + } + + @Test + void testGetSchema_CollectionNotFound() throws Exception { + // Given + final String nonExistentCollection = "non_existent_collection"; + + // When SolrClient throws an exception for non-existent collection + when(solrClient.request(any(SchemaRequest.class), eq(nonExistentCollection))) + .thenThrow(new SolrServerException("Collection not found: " + nonExistentCollection)); + + // Then + assertThrows(Exception.class, () -> { + schemaService.getSchema(nonExistentCollection); + }); + } + + @Test + void testGetSchema_SolrServerException() throws Exception { + // Given + final String collectionName = "test_collection"; + + // When SolrClient throws a SolrServerException + when(solrClient.request(any(SchemaRequest.class), eq(collectionName))) + .thenThrow(new SolrServerException("Solr server error")); + + // Then + assertThrows(Exception.class, () -> { + schemaService.getSchema(collectionName); + }); + } + + @Test + void testGetSchema_IOException() throws Exception { + // Given + final String collectionName = "test_collection"; + + // When SolrClient throws an IOException + when(solrClient.request(any(SchemaRequest.class), eq(collectionName))) + .thenThrow(new IOException("Network connection error")); + + // Then + assertThrows(Exception.class, () -> { + schemaService.getSchema(collectionName); + }); + } + + @Test + void testGetSchema_WithNullCollection() { + // Given a null collection name + // Then should throw an exception (NullPointerException or + // IllegalArgumentException) + assertThrows(Exception.class, () -> { + schemaService.getSchema(null); + }); + } + + @Test + void testGetSchema_WithEmptyCollection() { + // Given an empty collection name + // Then should throw an exception + assertThrows(Exception.class, () -> { + schemaService.getSchema(""); + }); + } + + @Test + void testConstructor() { + // Test that constructor properly initializes the service + SchemaService service = new SchemaService(solrClient); + assertNotNull(service); + } + + @Test + void testConstructor_WithNullClient() { + // Test constructor with null client + assertDoesNotThrow(() -> { + new SchemaService(null); + }); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java index 304bc52..8358b9f 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java @@ -41,252 +41,248 @@ @ExtendWith(MockitoExtension.class) class SearchServiceDirectTest { - @Mock private SolrClient solrClient; - - @Mock private QueryResponse queryResponse; - - private SearchService searchService; - - @BeforeEach - void setUp() { - searchService = new SearchService(solrClient); - } - - @Test - void testBasicSearch() throws SolrServerException, IOException { - // Setup mock response - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(2); - documents.setStart(0); - documents.setMaxScore(1.0f); - - SolrDocument doc1 = new SolrDocument(); - doc1.addField("id", "1"); - doc1.addField("name", List.of("Book 1")); - doc1.addField("author", List.of("Author 1")); - - SolrDocument doc2 = new SolrDocument(); - doc2.addField("id", "2"); - doc2.addField("name", List.of("Book 2")); - doc2.addField("author", List.of("Author 2")); - - documents.add(doc1); - documents.add(doc2); - - when(queryResponse.getResults()).thenReturn(documents); - when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); - - // Test - SearchResponse result = searchService.search("books", null, null, null, null, null, null); - - // Verify - assertNotNull(result); - assertEquals(2L, result.numFound()); - - List> resultDocs = result.documents(); - assertEquals(2, resultDocs.size()); - assertEquals("1", resultDocs.getFirst().get("id")); - assertEquals("2", resultDocs.get(1).get("id")); - } - - @Test - void testSearchWithFacets() throws SolrServerException, IOException { - // Setup mock response - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(2); - documents.setStart(0); - documents.setMaxScore(1.0f); - - SolrDocument doc1 = new SolrDocument(); - doc1.addField("id", "1"); - doc1.addField("genre_s", "fantasy"); - - SolrDocument doc2 = new SolrDocument(); - doc2.addField("id", "2"); - doc2.addField("genre_s", "scifi"); - - documents.add(doc1); - documents.add(doc2); - - // Create facet fields - List facetFields = new ArrayList<>(); - FacetField genreFacet = new FacetField("genre_s"); - genreFacet.add("fantasy", 1); - genreFacet.add("scifi", 1); - facetFields.add(genreFacet); - - when(queryResponse.getResults()).thenReturn(documents); - when(queryResponse.getFacetFields()).thenReturn(facetFields); - when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); - - // Test - SearchResponse result = searchService.search("books", null, null, null, null, null, null); - - // Verify - assertNotNull(result); - assertTrue(result.facets().containsKey("genre_s")); - - Map genreFacets = result.facets().get("genre_s"); - assertEquals(2, genreFacets.size()); - assertEquals(1L, genreFacets.get("fantasy")); - assertEquals(1L, genreFacets.get("scifi")); - } - - @Test - void testSearchWithEmptyResults() throws SolrServerException, IOException { - // Setup mock response with empty results - SolrDocumentList emptyDocuments = new SolrDocumentList(); - emptyDocuments.setNumFound(0); - emptyDocuments.setStart(0); - emptyDocuments.setMaxScore(0.0f); - - when(queryResponse.getResults()).thenReturn(emptyDocuments); - when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); - - // Test - SearchResponse result = - searchService.search("books", "nonexistent_query", null, null, null, null, null); - - // Verify - assertNotNull(result); - assertEquals(0L, result.numFound()); - assertEquals(0, result.documents().size()); - assertTrue(result.facets().isEmpty()); - } - - @Test - void testSearchWithEmptyFacets() throws SolrServerException, IOException { - // Setup mock response with documents but no facets - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(2); - documents.setStart(0); - documents.setMaxScore(1.0f); - - SolrDocument doc1 = new SolrDocument(); - doc1.addField("id", "1"); - doc1.addField("name", "Book 1"); - - SolrDocument doc2 = new SolrDocument(); - doc2.addField("id", "2"); - doc2.addField("name", "Book 2"); - - documents.add(doc1); - documents.add(doc2); - - when(queryResponse.getResults()).thenReturn(documents); - when(queryResponse.getFacetFields()).thenReturn(null); // No facet fields - when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); - - // Test with facet fields requested but none returned - SearchResponse result = - searchService.search("books", null, null, List.of("genre_s"), null, null, null); - - // Verify - assertNotNull(result); - assertEquals(2L, result.numFound()); - assertEquals(2, result.documents().size()); - assertTrue(result.facets().isEmpty()); - } - - @Test - void testSearchWithEmptyFacetValues() throws SolrServerException, IOException { - // Setup mock response with facet fields but no values - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(2); - documents.setStart(0); - documents.setMaxScore(1.0f); - - SolrDocument doc1 = new SolrDocument(); - doc1.addField("id", "1"); - SolrDocument doc2 = new SolrDocument(); - doc2.addField("id", "2"); - - documents.add(doc1); - documents.add(doc2); - - // Create facet field with no values - List facetFields = new ArrayList<>(); - FacetField emptyFacet = new FacetField("genre_s"); - facetFields.add(emptyFacet); - - when(queryResponse.getResults()).thenReturn(documents); - when(queryResponse.getFacetFields()).thenReturn(facetFields); - when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); - - // Test - SearchResponse result = - searchService.search("books", null, null, List.of("genre_s"), null, null, null); - - // Verify - assertNotNull(result); - assertEquals(2L, result.numFound()); - assertTrue(result.facets().containsKey("genre_s")); - assertTrue(result.facets().get("genre_s").isEmpty()); - } - - @Test - void testSearchWithSolrError() { - // Setup mock to throw exception - try { - when(solrClient.query(eq("books"), any(SolrQuery.class))) - .thenThrow(new SolrServerException("Simulated Solr server error")); - - // Test - assertThrows( - SolrServerException.class, - () -> { - searchService.search("books", null, null, null, null, null, null); - }); - } catch (Exception e) { - fail("Test setup failed: " + e.getMessage()); - } - } - - @Test - void testSearchWithAllParameters() throws SolrServerException, IOException { - // Setup mock response - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(1); - documents.setStart(5); - documents.setMaxScore(0.75f); - - SolrDocument doc = new SolrDocument(); - doc.addField("id", "5"); - doc.addField("name", "Book 5"); - doc.addField("author", "Author 5"); - doc.addField("genre_s", "mystery"); - doc.addField("price", 12.99); - - documents.add(doc); - - // Create facet fields - List facetFields = new ArrayList<>(); - FacetField genreFacet = new FacetField("genre_s"); - genreFacet.add("mystery", 1); - facetFields.add(genreFacet); - - when(queryResponse.getResults()).thenReturn(documents); - when(queryResponse.getFacetFields()).thenReturn(facetFields); - when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); - - // Test with all parameters - List filterQueries = List.of("price:[10 TO 15]"); - List facetFields2 = List.of("genre_s", "author"); - List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); - - SearchResponse result = - searchService.search( - "books", "mystery", filterQueries, facetFields2, sortClauses, 5, 10); - - // Verify - assertNotNull(result); - assertEquals(1L, result.numFound()); - assertEquals(5, result.start()); - assertEquals(0.75f, result.maxScore()); - assertEquals(1, result.documents().size()); - assertEquals("5", result.documents().getFirst().get("id")); - assertEquals("Book 5", result.documents().getFirst().get("name")); - assertTrue(result.facets().containsKey("genre_s")); - assertEquals(1L, result.facets().get("genre_s").get("mystery")); - } + @Mock + private SolrClient solrClient; + + @Mock + private QueryResponse queryResponse; + + private SearchService searchService; + + @BeforeEach + void setUp() { + searchService = new SearchService(solrClient); + } + + @Test + void testBasicSearch() throws SolrServerException, IOException { + // Setup mock response + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(2); + documents.setStart(0); + documents.setMaxScore(1.0f); + + SolrDocument doc1 = new SolrDocument(); + doc1.addField("id", "1"); + doc1.addField("name", List.of("Book 1")); + doc1.addField("author", List.of("Author 1")); + + SolrDocument doc2 = new SolrDocument(); + doc2.addField("id", "2"); + doc2.addField("name", List.of("Book 2")); + doc2.addField("author", List.of("Author 2")); + + documents.add(doc1); + documents.add(doc2); + + when(queryResponse.getResults()).thenReturn(documents); + when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); + + // Test + SearchResponse result = searchService.search("books", null, null, null, null, null, null); + + // Verify + assertNotNull(result); + assertEquals(2L, result.numFound()); + + List> resultDocs = result.documents(); + assertEquals(2, resultDocs.size()); + assertEquals("1", resultDocs.getFirst().get("id")); + assertEquals("2", resultDocs.get(1).get("id")); + } + + @Test + void testSearchWithFacets() throws SolrServerException, IOException { + // Setup mock response + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(2); + documents.setStart(0); + documents.setMaxScore(1.0f); + + SolrDocument doc1 = new SolrDocument(); + doc1.addField("id", "1"); + doc1.addField("genre_s", "fantasy"); + + SolrDocument doc2 = new SolrDocument(); + doc2.addField("id", "2"); + doc2.addField("genre_s", "scifi"); + + documents.add(doc1); + documents.add(doc2); + + // Create facet fields + List facetFields = new ArrayList<>(); + FacetField genreFacet = new FacetField("genre_s"); + genreFacet.add("fantasy", 1); + genreFacet.add("scifi", 1); + facetFields.add(genreFacet); + + when(queryResponse.getResults()).thenReturn(documents); + when(queryResponse.getFacetFields()).thenReturn(facetFields); + when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); + + // Test + SearchResponse result = searchService.search("books", null, null, null, null, null, null); + + // Verify + assertNotNull(result); + assertTrue(result.facets().containsKey("genre_s")); + + Map genreFacets = result.facets().get("genre_s"); + assertEquals(2, genreFacets.size()); + assertEquals(1L, genreFacets.get("fantasy")); + assertEquals(1L, genreFacets.get("scifi")); + } + + @Test + void testSearchWithEmptyResults() throws SolrServerException, IOException { + // Setup mock response with empty results + SolrDocumentList emptyDocuments = new SolrDocumentList(); + emptyDocuments.setNumFound(0); + emptyDocuments.setStart(0); + emptyDocuments.setMaxScore(0.0f); + + when(queryResponse.getResults()).thenReturn(emptyDocuments); + when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); + + // Test + SearchResponse result = searchService.search("books", "nonexistent_query", null, null, null, null, null); + + // Verify + assertNotNull(result); + assertEquals(0L, result.numFound()); + assertEquals(0, result.documents().size()); + assertTrue(result.facets().isEmpty()); + } + + @Test + void testSearchWithEmptyFacets() throws SolrServerException, IOException { + // Setup mock response with documents but no facets + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(2); + documents.setStart(0); + documents.setMaxScore(1.0f); + + SolrDocument doc1 = new SolrDocument(); + doc1.addField("id", "1"); + doc1.addField("name", "Book 1"); + + SolrDocument doc2 = new SolrDocument(); + doc2.addField("id", "2"); + doc2.addField("name", "Book 2"); + + documents.add(doc1); + documents.add(doc2); + + when(queryResponse.getResults()).thenReturn(documents); + when(queryResponse.getFacetFields()).thenReturn(null); // No facet fields + when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); + + // Test with facet fields requested but none returned + SearchResponse result = searchService.search("books", null, null, List.of("genre_s"), null, null, null); + + // Verify + assertNotNull(result); + assertEquals(2L, result.numFound()); + assertEquals(2, result.documents().size()); + assertTrue(result.facets().isEmpty()); + } + + @Test + void testSearchWithEmptyFacetValues() throws SolrServerException, IOException { + // Setup mock response with facet fields but no values + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(2); + documents.setStart(0); + documents.setMaxScore(1.0f); + + SolrDocument doc1 = new SolrDocument(); + doc1.addField("id", "1"); + SolrDocument doc2 = new SolrDocument(); + doc2.addField("id", "2"); + + documents.add(doc1); + documents.add(doc2); + + // Create facet field with no values + List facetFields = new ArrayList<>(); + FacetField emptyFacet = new FacetField("genre_s"); + facetFields.add(emptyFacet); + + when(queryResponse.getResults()).thenReturn(documents); + when(queryResponse.getFacetFields()).thenReturn(facetFields); + when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); + + // Test + SearchResponse result = searchService.search("books", null, null, List.of("genre_s"), null, null, null); + + // Verify + assertNotNull(result); + assertEquals(2L, result.numFound()); + assertTrue(result.facets().containsKey("genre_s")); + assertTrue(result.facets().get("genre_s").isEmpty()); + } + + @Test + void testSearchWithSolrError() { + // Setup mock to throw exception + try { + when(solrClient.query(eq("books"), any(SolrQuery.class))) + .thenThrow(new SolrServerException("Simulated Solr server error")); + + // Test + assertThrows(SolrServerException.class, () -> { + searchService.search("books", null, null, null, null, null, null); + }); + } catch (Exception e) { + fail("Test setup failed: " + e.getMessage()); + } + } + + @Test + void testSearchWithAllParameters() throws SolrServerException, IOException { + // Setup mock response + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(1); + documents.setStart(5); + documents.setMaxScore(0.75f); + + SolrDocument doc = new SolrDocument(); + doc.addField("id", "5"); + doc.addField("name", "Book 5"); + doc.addField("author", "Author 5"); + doc.addField("genre_s", "mystery"); + doc.addField("price", 12.99); + + documents.add(doc); + + // Create facet fields + List facetFields = new ArrayList<>(); + FacetField genreFacet = new FacetField("genre_s"); + genreFacet.add("mystery", 1); + facetFields.add(genreFacet); + + when(queryResponse.getResults()).thenReturn(documents); + when(queryResponse.getFacetFields()).thenReturn(facetFields); + when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); + + // Test with all parameters + List filterQueries = List.of("price:[10 TO 15]"); + List facetFields2 = List.of("genre_s", "author"); + List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); + + SearchResponse result = searchService.search("books", "mystery", filterQueries, facetFields2, sortClauses, 5, + 10); + + // Verify + assertNotNull(result); + assertEquals(1L, result.numFound()); + assertEquals(5, result.start()); + assertEquals(0.75f, result.maxScore()); + assertEquals(1, result.documents().size()); + assertEquals("5", result.documents().getFirst().get("id")); + assertEquals("Book 5", result.documents().getFirst().get("name")); + assertTrue(result.facets().containsKey("genre_s")); + assertEquals(1L, result.facets().get("genre_s").get("mystery")); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java index fc1ed07..a822cb5 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java @@ -43,733 +43,666 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -/** Combined tests for SearchService: integration + unit (mocked SolrClient) in one class. */ +/** + * Combined tests for SearchService: integration + unit (mocked SolrClient) in + * one class. + */ @SpringBootTest @Import(TestcontainersConfiguration.class) class SearchServiceTest { - // ===== Integration test context ===== - private static final String COLLECTION_NAME = "search_test_" + System.currentTimeMillis(); - - @Autowired private SearchService searchService; - @Autowired private IndexingService indexingService; - @Autowired private SolrClient solrClient; - - private static boolean initialized = false; - - @BeforeEach - void setUp() throws Exception { - if (!initialized) { - CollectionAdminRequest.Create createRequest = - CollectionAdminRequest.createCollection(COLLECTION_NAME, "_default", 1, 1); - createRequest.process(solrClient); - - String sampleData = - """ - [ - { - "id": "book001", - "name": ["A Game of Thrones"], - "author_ss": ["George R.R. Martin"], - "price": [7.99], - "genre_s": "fantasy", - "series_s": "A Song of Ice and Fire", - "sequence_i": 1, - "cat_ss": ["book"] - }, - { - "id": "book002", - "name": ["A Clash of Kings"], - "author_ss": ["George R.R. Martin"], - "price": [8.99], - "genre_s": "fantasy", - "series_s": "A Song of Ice and Fire", - "sequence_i": 2, - "cat_ss": ["book"] - }, - { - "id": "book003", - "name": ["A Storm of Swords"], - "author_ss": ["George R.R. Martin"], - "price": [9.99], - "genre_s": "fantasy", - "series_s": "A Song of Ice and Fire", - "sequence_i": 3, - "cat_ss": ["book"] - }, - { - "id": "book004", - "name": ["The Hobbit"], - "author_ss": ["J.R.R. Tolkien"], - "price": [6.99], - "genre_s": "fantasy", - "series_s": "Middle Earth", - "sequence_i": 1, - "cat_ss": ["book"] - }, - { - "id": "book005", - "name": ["Dune"], - "author_ss": ["Frank Herbert"], - "price": [10.99], - "genre_s": "scifi", - "series_s": "Dune", - "sequence_i": 1, - "cat_ss": ["book"] - }, - { - "id": "book006", - "name": ["Foundation"], - "author_ss": ["Isaac Asimov"], - "price": [5.99], - "genre_s": "scifi", - "series_s": "Foundation", - "sequence_i": 1, - "cat_ss": ["book"] - }, - { - "id": "book007", - "name": ["The Fellowship of the Ring"], - "author_ss": ["J.R.R. Tolkien"], - "price": [8.99], - "genre_s": "fantasy", - "series_s": "The Lord of the Rings", - "sequence_i": 1, - "cat_ss": ["book"] - }, - { - "id": "book008", - "name": ["The Two Towers"], - "author_ss": ["J.R.R. Tolkien"], - "price": [8.99], - "genre_s": "fantasy", - "series_s": "The Lord of the Rings", - "sequence_i": 2, - "cat_ss": ["book"] - }, - { - "id": "book009", - "name": ["The Return of the King"], - "author_ss": ["J.R.R. Tolkien"], - "price": [8.99], - "genre_s": "fantasy", - "series_s": "The Lord of the Rings", - "sequence_i": 3, - "cat_ss": ["book"] - }, - { - "id": "book010", - "name": ["Neuromancer"], - "author_ss": ["William Gibson"], - "price": [7.99], - "genre_s": "scifi", - "series_s": "Sprawl", - "sequence_i": 1, - "cat_ss": ["book"] - } - ] - """; - - indexingService.indexJsonDocuments(COLLECTION_NAME, sampleData); - solrClient.commit(COLLECTION_NAME); - initialized = true; - } - } - - // ===== Integration tests (from original SearchServiceTest) ===== - - @Test - void testBasicSearch() throws SolrServerException, IOException { - SearchResponse result = - searchService.search(COLLECTION_NAME, null, null, null, null, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - assertEquals(10, documents.size()); - } - - @Test - void testSearchWithQuery() throws SolrServerException, IOException { - SearchResponse result = - searchService.search( - COLLECTION_NAME, "name:\"Game of Thrones\"", null, null, null, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertEquals(1, documents.size()); - Map book = documents.getFirst(); - assertEquals("A Game of Thrones", ((List) book.get("name")).getFirst()); - } - - @Test - void testSearchReturnsAuthor() throws Exception { - SearchResponse result = - searchService.search( - COLLECTION_NAME, - "author_ss:\"George R.R. Martin\"", - null, - null, - null, - null, - null); - assertNotNull(result); - List> documents = result.documents(); - assertEquals(3, documents.size()); - Map book = documents.getFirst(); - assertEquals("George R.R. Martin", ((List) book.get("author_ss")).getFirst()); - } - - @Test - void testSearchWithFacets() throws Exception { - SearchResponse result = - searchService.search( - COLLECTION_NAME, null, null, List.of("genre_s"), null, null, null); - assertNotNull(result); - Map> facets = result.facets(); - assertNotNull(facets); - assertTrue(facets.containsKey("genre_s")); - } - - @Test - void testSearchWithPrice() throws Exception { - SearchResponse result = - searchService.search(COLLECTION_NAME, null, null, null, null, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - Map book = documents.getFirst(); - double currentPrice = - ((List) book.get("price")).isEmpty() - ? 0.0 - : ((Number) ((List) book.get("price")).getFirst()).doubleValue(); - assertTrue(currentPrice > 0); - } - - @Test - void testSortByPriceAscending() throws Exception { - List> sortClauses = List.of(Map.of("item", "price", "order", "asc")); - SearchResponse result = - searchService.search(COLLECTION_NAME, null, null, null, sortClauses, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - double previousPrice = 0.0; - for (Map book : documents) { - OptionalDouble priceOpt = extractPrice(book); - if (priceOpt.isEmpty()) continue; - double currentPrice = priceOpt.getAsDouble(); - assertTrue( - currentPrice >= previousPrice, - "Books should be sorted by price in ascending order"); - previousPrice = currentPrice; - } - } - - @Test - void testSortByPriceDescending() throws Exception { - List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); - SearchResponse result = - searchService.search(COLLECTION_NAME, null, null, null, sortClauses, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - double previousPrice = Double.MAX_VALUE; - for (Map book : documents) { - OptionalDouble priceOpt = extractPrice(book); - if (priceOpt.isEmpty()) continue; - double currentPrice = priceOpt.getAsDouble(); - assertTrue( - currentPrice <= previousPrice, - "Books should be sorted by price in descending order"); - previousPrice = currentPrice; - } - } - - @Test - void testSortBySequence() throws Exception { - List> sortClauses = - List.of(Map.of("item", "sequence_i", "order", "asc")); - List filterQueries = List.of("series_s:\"A Song of Ice and Fire\""); - SearchResponse result = - searchService.search( - COLLECTION_NAME, null, filterQueries, null, sortClauses, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - int previousSequence = 0; - for (Map book : documents) { - int currentSequence = ((Number) book.get("sequence_i")).intValue(); - assertTrue( - currentSequence >= previousSequence, - "Books should be sorted by sequence_i in ascending order"); - previousSequence = currentSequence; - } - } - - @Test - void testFilterByGenre() throws Exception { - List filterQueries = List.of("genre_s:fantasy"); - SearchResponse result = - searchService.search(COLLECTION_NAME, null, filterQueries, null, null, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - for (Map book : documents) { - String genre = (String) book.get("genre_s"); - assertEquals("fantasy", genre, "All books should have genre_s = fantasy"); - } - } - - @Test - void testFilterByPriceRange() throws Exception { - List filterQueries = List.of("price:[6.0 TO 7.0]"); - SearchResponse result = - searchService.search(COLLECTION_NAME, null, filterQueries, null, null, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - for (Map book : documents) { - if (book.get("price") == null) continue; - OptionalDouble priceOpt = extractPrice(book); - if (priceOpt.isEmpty()) continue; - double price = priceOpt.getAsDouble(); - assertTrue( - price >= 6.0 && price <= 7.0, - "All books should have price between 6.0 and 7.0"); - } - } - - @Test - void testCombinedSortingAndFiltering() throws Exception { - List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); - List filterQueries = List.of("genre_s:fantasy"); - SearchResponse result = - searchService.search( - COLLECTION_NAME, null, filterQueries, null, sortClauses, null, null); - assertNotNull(result); - List> documents = result.documents(); - assertFalse(documents.isEmpty()); - for (Map book : documents) { - String genre = (String) book.get("genre_s"); - assertEquals("fantasy", genre, "All books should have genre_s = fantasy"); - } - double previousPrice = Double.MAX_VALUE; - for (Map book : documents) { - Object priceObj = book.get("price"); - double currentPrice; - if (priceObj instanceof List) { - List priceList = (List) priceObj; - if (priceList.isEmpty()) { - continue; - } - currentPrice = ((Number) priceList.getFirst()).doubleValue(); - } else if (priceObj instanceof Number) { - currentPrice = ((Number) priceObj).doubleValue(); - } else { - continue; - } - assertTrue( - currentPrice <= previousPrice, - "Books should be sorted by price in descending order"); - previousPrice = currentPrice; - } - } - - @Test - void testPagination() throws Exception { - SearchResponse allResults = - searchService.search(COLLECTION_NAME, null, null, null, null, null, null); - assertNotNull(allResults); - long totalDocuments = allResults.numFound(); - assertTrue(totalDocuments > 0, "Should have at least some documents"); - SearchResponse firstPage = - searchService.search(COLLECTION_NAME, null, null, null, null, 0, 2); - assertNotNull(firstPage); - assertEquals(0, firstPage.start(), "Start offset should be 0"); - assertEquals(totalDocuments, firstPage.numFound(), "Total count should match"); - assertEquals(2, firstPage.documents().size(), "Should return exactly 2 documents"); - SearchResponse secondPage = - searchService.search(COLLECTION_NAME, null, null, null, null, 2, 2); - assertNotNull(secondPage); - assertEquals(2, secondPage.start(), "Start offset should be 2"); - assertEquals(totalDocuments, secondPage.numFound(), "Total count should match"); - assertEquals(2, secondPage.documents().size(), "Should return exactly 2 documents"); - List firstPageIds = getDocumentIds(firstPage.documents()); - List secondPageIds = getDocumentIds(secondPage.documents()); - for (String id : firstPageIds) { - assertFalse( - secondPageIds.contains(id), - "Second page should not contain documents from first page"); - } - } - - @Test - void testSpecialCharactersInQuery() throws Exception { - String specialJson = - """ -[ - { - "id": "special001", - "title": "Book with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /", - "author_ss": ["Special Author (with parentheses)"], - "description": "This is a test document with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /" - } -] -"""; - indexingService.indexJsonDocuments(COLLECTION_NAME, specialJson); - solrClient.commit(COLLECTION_NAME); - String query = "id:special001"; - SearchResponse result = - searchService.search(COLLECTION_NAME, query, null, null, null, null, null); - assertNotNull(result); - assertEquals(1, result.numFound(), "Should find exactly one document"); - query = "author_ss:\"Special Author \\(" + "with parentheses\\)\""; // escape parentheses - result = searchService.search(COLLECTION_NAME, query, null, null, null, null, null); - assertNotNull(result); - assertEquals(1, result.numFound(), "Should find exactly one document"); - query = "title:special*"; - result = searchService.search(COLLECTION_NAME, query, null, null, null, null, null); - assertNotNull(result); - assertTrue(result.numFound() > 0, "Should find at least one document"); - } - - // ===== Unit-style tests with mocked SolrClient (from SearchServiceUnitTest) ===== - - @Test - void unit_constructor_ShouldInitializeWithSolrClient() { - SearchService localService = new SearchService(mock(SolrClient.class)); - assertNotNull(localService); - } - - @Test - void unit_search_WithNullQuery_ShouldDefaultToMatchAll() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery q = invocation.getArgument(1); - assertEquals("*:*", q.getQuery()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, null, null, null, null, null); - assertNotNull(result); - } - - @Test - void unit_search_WithCustomQuery_ShouldUseProvidedQuery() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - String customQuery = "name:\"Spring Boot\""; - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery q = invocation.getArgument(1); - assertEquals(customQuery, q.getQuery()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", customQuery, null, null, null, null, null); - assertNotNull(result); - } - - @Test - void unit_search_WithFilterQueries_ShouldApplyFilters() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - List filterQueries = List.of("genre_s:fantasy", "price:[0 TO 10]"); - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery q = invocation.getArgument(1); - assertArrayEquals(filterQueries.toArray(), q.getFilterQueries()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, filterQueries, null, null, null, null); - assertNotNull(result); - } - - @Test - void unit_search_WithFacetFields_ShouldEnableFaceting() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - List facetFields = List.of("genre_s", "author_ss"); - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer(invocation -> mockResponse); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, null, facetFields, null, null, null); - assertNotNull(result); - assertNotNull(result.facets()); - } - - @Test - void unit_search_WithSortClauses_ShouldApplySorting() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - List> sortClauses = - List.of( - Map.of("item", "price", "order", "asc"), - Map.of("item", "name", "order", "desc")); - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer(invocation -> mockResponse); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, null, null, sortClauses, null, null); - assertNotNull(result); - } - - @Test - void unit_search_WithPagination_ShouldApplyStartAndRows() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - Integer start = 10; - Integer rows = 20; - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery q = invocation.getArgument(1); - assertEquals(start, q.getStart()); - assertEquals(rows, q.getRows()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, null, null, null, start, rows); - assertNotNull(result); - } - - @Test - void unit_search_WithAllParameters_ShouldCombineAllOptions() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - String query = "title:Java"; - List filterQueries = List.of("inStock:true"); - List facetFields = List.of("category"); - List> sortClauses = List.of(Map.of("item", "price", "order", "asc")); - Integer start = 0; - Integer rows = 10; - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery captured = invocation.getArgument(1); - assertEquals(query, captured.getQuery()); - assertArrayEquals(filterQueries.toArray(), captured.getFilterQueries()); - assertNotNull(captured.getFacetFields()); - assertEquals(start, captured.getStart()); - assertEquals(rows, captured.getRows()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search( - "test_collection", - query, - filterQueries, - facetFields, - sortClauses, - start, - rows); - assertNotNull(result); - } - - @Test - void unit_search_WhenSolrThrowsException_ShouldPropagateException() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenThrow(new SolrServerException("Connection error")); - SearchService localService = new SearchService(mockClient); - assertThrows( - SolrServerException.class, - () -> localService.search("test_collection", null, null, null, null, null, null)); - } - - @Test - void unit_search_WhenIOException_ShouldPropagateException() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenThrow(new IOException("Network error")); - SearchService localService = new SearchService(mockClient); - assertThrows( - IOException.class, - () -> localService.search("test_collection", null, null, null, null, null, null)); - } - - @Test - void unit_search_WithEmptyResults_ShouldReturnEmptyDocumentList() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - SolrDocumentList emptyDocuments = new SolrDocumentList(); - emptyDocuments.setNumFound(0); - emptyDocuments.setStart(0); - when(mockResponse.getResults()).thenReturn(emptyDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenReturn(mockResponse); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search( - "test_collection", "nonexistent:value", null, null, null, null, null); - assertNotNull(result); - assertEquals(0, result.numFound()); - assertTrue(result.documents().isEmpty()); - } - - @Test - void unit_search_WithNullFilterQueries_ShouldNotApplyFilters() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery q = invocation.getArgument(1); - assertNull(q.getFilterQueries()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, null, null, null, null, null); - assertNotNull(result); - } - - @Test - void unit_search_WithEmptyFacetFields_ShouldNotEnableFaceting() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - SolrDocumentList mockDocuments = createMockDocumentList(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenAnswer( - invocation -> { - SolrQuery q = invocation.getArgument(1); - assertNull(q.getFacetFields()); - return mockResponse; - }); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search("test_collection", null, null, List.of(), null, null, null); - assertNotNull(result); - } - - @Test - void unit_searchResponse_ShouldContainAllFields() throws Exception { - SolrClient mockClient = mock(SolrClient.class); - QueryResponse mockResponse = mock(QueryResponse.class); - SolrDocumentList mockDocuments = createMockDocumentListWithData(); - when(mockResponse.getResults()).thenReturn(mockDocuments); - when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) - .thenReturn(mockResponse); - SearchService localService = new SearchService(mockClient); - SearchResponse result = - localService.search( - "test_collection", null, null, List.of("genre_s"), null, null, null); - assertNotNull(result); - assertEquals(2, result.numFound()); - assertEquals(0, result.start()); - assertNotNull(result.documents()); - assertEquals(2, result.documents().size()); - assertNotNull(result.facets()); - assertFalse(result.facets().isEmpty()); - } - - // ===== Helpers (from unit tests and integration tests) ===== - - private OptionalDouble extractPrice(Map document) { - Object priceObj = document.get("price"); - if (priceObj == null) { - return OptionalDouble.empty(); - } - if (priceObj instanceof List) { - List priceList = (List) priceObj; - if (priceList.isEmpty()) { - return OptionalDouble.empty(); - } - return OptionalDouble.of(((Number) priceList.getFirst()).doubleValue()); - } else if (priceObj instanceof Number) { - return OptionalDouble.of(((Number) priceObj).doubleValue()); - } - return OptionalDouble.empty(); - } - - private List getDocumentIds(List> documents) { - List ids = new ArrayList<>(); - for (Map doc : documents) { - Object idObj = doc.get("id"); - if (idObj instanceof List) { - ids.add(((List) idObj).getFirst().toString()); - } else if (idObj != null) { - ids.add(idObj.toString()); - } - } - return ids; - } - - private SolrDocumentList createMockDocumentList() { - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(0); - documents.setStart(0); - return documents; - } - - private SolrDocumentList createMockDocumentListWithData() { - SolrDocumentList documents = new SolrDocumentList(); - documents.setNumFound(2); - documents.setStart(0); - documents.setMaxScore(1.0f); - SolrDocument doc1 = new SolrDocument(); - doc1.setField("id", "book001"); - doc1.setField("name", "Spring Boot in Action"); - doc1.setField("author_ss", List.of("Craig Walls")); - doc1.setField("price", 39.99); - doc1.setField("genre_s", "technology"); - documents.add(doc1); - SolrDocument doc2 = new SolrDocument(); - doc2.setField("id", "book002"); - doc2.setField("name", "Effective Java"); - doc2.setField("author_ss", List.of("Joshua Bloch")); - doc2.setField("price", 44.99); - doc2.setField("genre_s", "technology"); - documents.add(doc2); - return documents; - } - - private List createMockFacetFields() { - FacetField genreFacet = new FacetField("genre_s"); - genreFacet.add("technology", 5); - genreFacet.add("fiction", 3); - FacetField authorFacet = new FacetField("author_ss"); - authorFacet.add("Craig Walls", 2); - authorFacet.add("Joshua Bloch", 1); - return List.of(genreFacet, authorFacet); - } + // ===== Integration test context ===== + private static final String COLLECTION_NAME = "search_test_" + System.currentTimeMillis(); + + @Autowired + private SearchService searchService; + @Autowired + private IndexingService indexingService; + @Autowired + private SolrClient solrClient; + + private static boolean initialized = false; + + @BeforeEach + void setUp() throws Exception { + if (!initialized) { + CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection(COLLECTION_NAME, + "_default", 1, 1); + createRequest.process(solrClient); + + String sampleData = """ + [ + { + "id": "book001", + "name": ["A Game of Thrones"], + "author_ss": ["George R.R. Martin"], + "price": [7.99], + "genre_s": "fantasy", + "series_s": "A Song of Ice and Fire", + "sequence_i": 1, + "cat_ss": ["book"] + }, + { + "id": "book002", + "name": ["A Clash of Kings"], + "author_ss": ["George R.R. Martin"], + "price": [8.99], + "genre_s": "fantasy", + "series_s": "A Song of Ice and Fire", + "sequence_i": 2, + "cat_ss": ["book"] + }, + { + "id": "book003", + "name": ["A Storm of Swords"], + "author_ss": ["George R.R. Martin"], + "price": [9.99], + "genre_s": "fantasy", + "series_s": "A Song of Ice and Fire", + "sequence_i": 3, + "cat_ss": ["book"] + }, + { + "id": "book004", + "name": ["The Hobbit"], + "author_ss": ["J.R.R. Tolkien"], + "price": [6.99], + "genre_s": "fantasy", + "series_s": "Middle Earth", + "sequence_i": 1, + "cat_ss": ["book"] + }, + { + "id": "book005", + "name": ["Dune"], + "author_ss": ["Frank Herbert"], + "price": [10.99], + "genre_s": "scifi", + "series_s": "Dune", + "sequence_i": 1, + "cat_ss": ["book"] + }, + { + "id": "book006", + "name": ["Foundation"], + "author_ss": ["Isaac Asimov"], + "price": [5.99], + "genre_s": "scifi", + "series_s": "Foundation", + "sequence_i": 1, + "cat_ss": ["book"] + }, + { + "id": "book007", + "name": ["The Fellowship of the Ring"], + "author_ss": ["J.R.R. Tolkien"], + "price": [8.99], + "genre_s": "fantasy", + "series_s": "The Lord of the Rings", + "sequence_i": 1, + "cat_ss": ["book"] + }, + { + "id": "book008", + "name": ["The Two Towers"], + "author_ss": ["J.R.R. Tolkien"], + "price": [8.99], + "genre_s": "fantasy", + "series_s": "The Lord of the Rings", + "sequence_i": 2, + "cat_ss": ["book"] + }, + { + "id": "book009", + "name": ["The Return of the King"], + "author_ss": ["J.R.R. Tolkien"], + "price": [8.99], + "genre_s": "fantasy", + "series_s": "The Lord of the Rings", + "sequence_i": 3, + "cat_ss": ["book"] + }, + { + "id": "book010", + "name": ["Neuromancer"], + "author_ss": ["William Gibson"], + "price": [7.99], + "genre_s": "scifi", + "series_s": "Sprawl", + "sequence_i": 1, + "cat_ss": ["book"] + } + ] + """; + + indexingService.indexJsonDocuments(COLLECTION_NAME, sampleData); + solrClient.commit(COLLECTION_NAME); + initialized = true; + } + } + + // ===== Integration tests (from original SearchServiceTest) ===== + + @Test + void testBasicSearch() throws SolrServerException, IOException { + SearchResponse result = searchService.search(COLLECTION_NAME, null, null, null, null, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + assertEquals(10, documents.size()); + } + + @Test + void testSearchWithQuery() throws SolrServerException, IOException { + SearchResponse result = searchService.search(COLLECTION_NAME, "name:\"Game of Thrones\"", null, null, null, + null, null); + assertNotNull(result); + List> documents = result.documents(); + assertEquals(1, documents.size()); + Map book = documents.getFirst(); + assertEquals("A Game of Thrones", ((List) book.get("name")).getFirst()); + } + + @Test + void testSearchReturnsAuthor() throws Exception { + SearchResponse result = searchService.search(COLLECTION_NAME, "author_ss:\"George R.R. Martin\"", null, null, + null, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertEquals(3, documents.size()); + Map book = documents.getFirst(); + assertEquals("George R.R. Martin", ((List) book.get("author_ss")).getFirst()); + } + + @Test + void testSearchWithFacets() throws Exception { + SearchResponse result = searchService.search(COLLECTION_NAME, null, null, List.of("genre_s"), null, null, null); + assertNotNull(result); + Map> facets = result.facets(); + assertNotNull(facets); + assertTrue(facets.containsKey("genre_s")); + } + + @Test + void testSearchWithPrice() throws Exception { + SearchResponse result = searchService.search(COLLECTION_NAME, null, null, null, null, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + Map book = documents.getFirst(); + double currentPrice = ((List) book.get("price")).isEmpty() + ? 0.0 + : ((Number) ((List) book.get("price")).getFirst()).doubleValue(); + assertTrue(currentPrice > 0); + } + + @Test + void testSortByPriceAscending() throws Exception { + List> sortClauses = List.of(Map.of("item", "price", "order", "asc")); + SearchResponse result = searchService.search(COLLECTION_NAME, null, null, null, sortClauses, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + double previousPrice = 0.0; + for (Map book : documents) { + OptionalDouble priceOpt = extractPrice(book); + if (priceOpt.isEmpty()) + continue; + double currentPrice = priceOpt.getAsDouble(); + assertTrue(currentPrice >= previousPrice, "Books should be sorted by price in ascending order"); + previousPrice = currentPrice; + } + } + + @Test + void testSortByPriceDescending() throws Exception { + List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); + SearchResponse result = searchService.search(COLLECTION_NAME, null, null, null, sortClauses, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + double previousPrice = Double.MAX_VALUE; + for (Map book : documents) { + OptionalDouble priceOpt = extractPrice(book); + if (priceOpt.isEmpty()) + continue; + double currentPrice = priceOpt.getAsDouble(); + assertTrue(currentPrice <= previousPrice, "Books should be sorted by price in descending order"); + previousPrice = currentPrice; + } + } + + @Test + void testSortBySequence() throws Exception { + List> sortClauses = List.of(Map.of("item", "sequence_i", "order", "asc")); + List filterQueries = List.of("series_s:\"A Song of Ice and Fire\""); + SearchResponse result = searchService.search(COLLECTION_NAME, null, filterQueries, null, sortClauses, null, + null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + int previousSequence = 0; + for (Map book : documents) { + int currentSequence = ((Number) book.get("sequence_i")).intValue(); + assertTrue(currentSequence >= previousSequence, "Books should be sorted by sequence_i in ascending order"); + previousSequence = currentSequence; + } + } + + @Test + void testFilterByGenre() throws Exception { + List filterQueries = List.of("genre_s:fantasy"); + SearchResponse result = searchService.search(COLLECTION_NAME, null, filterQueries, null, null, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + for (Map book : documents) { + String genre = (String) book.get("genre_s"); + assertEquals("fantasy", genre, "All books should have genre_s = fantasy"); + } + } + + @Test + void testFilterByPriceRange() throws Exception { + List filterQueries = List.of("price:[6.0 TO 7.0]"); + SearchResponse result = searchService.search(COLLECTION_NAME, null, filterQueries, null, null, null, null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + for (Map book : documents) { + if (book.get("price") == null) + continue; + OptionalDouble priceOpt = extractPrice(book); + if (priceOpt.isEmpty()) + continue; + double price = priceOpt.getAsDouble(); + assertTrue(price >= 6.0 && price <= 7.0, "All books should have price between 6.0 and 7.0"); + } + } + + @Test + void testCombinedSortingAndFiltering() throws Exception { + List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); + List filterQueries = List.of("genre_s:fantasy"); + SearchResponse result = searchService.search(COLLECTION_NAME, null, filterQueries, null, sortClauses, null, + null); + assertNotNull(result); + List> documents = result.documents(); + assertFalse(documents.isEmpty()); + for (Map book : documents) { + String genre = (String) book.get("genre_s"); + assertEquals("fantasy", genre, "All books should have genre_s = fantasy"); + } + double previousPrice = Double.MAX_VALUE; + for (Map book : documents) { + Object priceObj = book.get("price"); + double currentPrice; + if (priceObj instanceof List) { + List priceList = (List) priceObj; + if (priceList.isEmpty()) { + continue; + } + currentPrice = ((Number) priceList.getFirst()).doubleValue(); + } else if (priceObj instanceof Number) { + currentPrice = ((Number) priceObj).doubleValue(); + } else { + continue; + } + assertTrue(currentPrice <= previousPrice, "Books should be sorted by price in descending order"); + previousPrice = currentPrice; + } + } + + @Test + void testPagination() throws Exception { + SearchResponse allResults = searchService.search(COLLECTION_NAME, null, null, null, null, null, null); + assertNotNull(allResults); + long totalDocuments = allResults.numFound(); + assertTrue(totalDocuments > 0, "Should have at least some documents"); + SearchResponse firstPage = searchService.search(COLLECTION_NAME, null, null, null, null, 0, 2); + assertNotNull(firstPage); + assertEquals(0, firstPage.start(), "Start offset should be 0"); + assertEquals(totalDocuments, firstPage.numFound(), "Total count should match"); + assertEquals(2, firstPage.documents().size(), "Should return exactly 2 documents"); + SearchResponse secondPage = searchService.search(COLLECTION_NAME, null, null, null, null, 2, 2); + assertNotNull(secondPage); + assertEquals(2, secondPage.start(), "Start offset should be 2"); + assertEquals(totalDocuments, secondPage.numFound(), "Total count should match"); + assertEquals(2, secondPage.documents().size(), "Should return exactly 2 documents"); + List firstPageIds = getDocumentIds(firstPage.documents()); + List secondPageIds = getDocumentIds(secondPage.documents()); + for (String id : firstPageIds) { + assertFalse(secondPageIds.contains(id), "Second page should not contain documents from first page"); + } + } + + @Test + void testSpecialCharactersInQuery() throws Exception { + String specialJson = """ + [ + { + "id": "special001", + "title": "Book with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /", + "author_ss": ["Special Author (with parentheses)"], + "description": "This is a test document with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /" + } + ] + """; + indexingService.indexJsonDocuments(COLLECTION_NAME, specialJson); + solrClient.commit(COLLECTION_NAME); + String query = "id:special001"; + SearchResponse result = searchService.search(COLLECTION_NAME, query, null, null, null, null, null); + assertNotNull(result); + assertEquals(1, result.numFound(), "Should find exactly one document"); + query = "author_ss:\"Special Author \\(" + "with parentheses\\)\""; // escape parentheses + result = searchService.search(COLLECTION_NAME, query, null, null, null, null, null); + assertNotNull(result); + assertEquals(1, result.numFound(), "Should find exactly one document"); + query = "title:special*"; + result = searchService.search(COLLECTION_NAME, query, null, null, null, null, null); + assertNotNull(result); + assertTrue(result.numFound() > 0, "Should find at least one document"); + } + + // ===== Unit-style tests with mocked SolrClient (from SearchServiceUnitTest) + // ===== + + @Test + void unit_constructor_ShouldInitializeWithSolrClient() { + SearchService localService = new SearchService(mock(SolrClient.class)); + assertNotNull(localService); + } + + @Test + void unit_search_WithNullQuery_ShouldDefaultToMatchAll() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery q = invocation.getArgument(1); + assertEquals("*:*", q.getQuery()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, null, null, null, null); + assertNotNull(result); + } + + @Test + void unit_search_WithCustomQuery_ShouldUseProvidedQuery() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + String customQuery = "name:\"Spring Boot\""; + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery q = invocation.getArgument(1); + assertEquals(customQuery, q.getQuery()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", customQuery, null, null, null, null, null); + assertNotNull(result); + } + + @Test + void unit_search_WithFilterQueries_ShouldApplyFilters() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + List filterQueries = List.of("genre_s:fantasy", "price:[0 TO 10]"); + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery q = invocation.getArgument(1); + assertArrayEquals(filterQueries.toArray(), q.getFilterQueries()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, filterQueries, null, null, null, null); + assertNotNull(result); + } + + @Test + void unit_search_WithFacetFields_ShouldEnableFaceting() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + List facetFields = List.of("genre_s", "author_ss"); + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> mockResponse); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, facetFields, null, null, null); + assertNotNull(result); + assertNotNull(result.facets()); + } + + @Test + void unit_search_WithSortClauses_ShouldApplySorting() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + List> sortClauses = List.of(Map.of("item", "price", "order", "asc"), + Map.of("item", "name", "order", "desc")); + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> mockResponse); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, null, sortClauses, null, null); + assertNotNull(result); + } + + @Test + void unit_search_WithPagination_ShouldApplyStartAndRows() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + Integer start = 10; + Integer rows = 20; + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery q = invocation.getArgument(1); + assertEquals(start, q.getStart()); + assertEquals(rows, q.getRows()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, null, null, start, rows); + assertNotNull(result); + } + + @Test + void unit_search_WithAllParameters_ShouldCombineAllOptions() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + String query = "title:Java"; + List filterQueries = List.of("inStock:true"); + List facetFields = List.of("category"); + List> sortClauses = List.of(Map.of("item", "price", "order", "asc")); + Integer start = 0; + Integer rows = 10; + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery captured = invocation.getArgument(1); + assertEquals(query, captured.getQuery()); + assertArrayEquals(filterQueries.toArray(), captured.getFilterQueries()); + assertNotNull(captured.getFacetFields()); + assertEquals(start, captured.getStart()); + assertEquals(rows, captured.getRows()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", query, filterQueries, facetFields, sortClauses, + start, rows); + assertNotNull(result); + } + + @Test + void unit_search_WhenSolrThrowsException_ShouldPropagateException() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenThrow(new SolrServerException("Connection error")); + SearchService localService = new SearchService(mockClient); + assertThrows(SolrServerException.class, + () -> localService.search("test_collection", null, null, null, null, null, null)); + } + + @Test + void unit_search_WhenIOException_ShouldPropagateException() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenThrow(new IOException("Network error")); + SearchService localService = new SearchService(mockClient); + assertThrows(IOException.class, + () -> localService.search("test_collection", null, null, null, null, null, null)); + } + + @Test + void unit_search_WithEmptyResults_ShouldReturnEmptyDocumentList() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + SolrDocumentList emptyDocuments = new SolrDocumentList(); + emptyDocuments.setNumFound(0); + emptyDocuments.setStart(0); + when(mockResponse.getResults()).thenReturn(emptyDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenReturn(mockResponse); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", "nonexistent:value", null, null, null, null, + null); + assertNotNull(result); + assertEquals(0, result.numFound()); + assertTrue(result.documents().isEmpty()); + } + + @Test + void unit_search_WithNullFilterQueries_ShouldNotApplyFilters() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery q = invocation.getArgument(1); + assertNull(q.getFilterQueries()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, null, null, null, null); + assertNotNull(result); + } + + @Test + void unit_search_WithEmptyFacetFields_ShouldNotEnableFaceting() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + SolrDocumentList mockDocuments = createMockDocumentList(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(null); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { + SolrQuery q = invocation.getArgument(1); + assertNull(q.getFacetFields()); + return mockResponse; + }); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, List.of(), null, null, null); + assertNotNull(result); + } + + @Test + void unit_searchResponse_ShouldContainAllFields() throws Exception { + SolrClient mockClient = mock(SolrClient.class); + QueryResponse mockResponse = mock(QueryResponse.class); + SolrDocumentList mockDocuments = createMockDocumentListWithData(); + when(mockResponse.getResults()).thenReturn(mockDocuments); + when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenReturn(mockResponse); + SearchService localService = new SearchService(mockClient); + SearchResponse result = localService.search("test_collection", null, null, List.of("genre_s"), null, null, + null); + assertNotNull(result); + assertEquals(2, result.numFound()); + assertEquals(0, result.start()); + assertNotNull(result.documents()); + assertEquals(2, result.documents().size()); + assertNotNull(result.facets()); + assertFalse(result.facets().isEmpty()); + } + + // ===== Helpers (from unit tests and integration tests) ===== + + private OptionalDouble extractPrice(Map document) { + Object priceObj = document.get("price"); + if (priceObj == null) { + return OptionalDouble.empty(); + } + if (priceObj instanceof List) { + List priceList = (List) priceObj; + if (priceList.isEmpty()) { + return OptionalDouble.empty(); + } + return OptionalDouble.of(((Number) priceList.getFirst()).doubleValue()); + } else if (priceObj instanceof Number) { + return OptionalDouble.of(((Number) priceObj).doubleValue()); + } + return OptionalDouble.empty(); + } + + private List getDocumentIds(List> documents) { + List ids = new ArrayList<>(); + for (Map doc : documents) { + Object idObj = doc.get("id"); + if (idObj instanceof List) { + ids.add(((List) idObj).getFirst().toString()); + } else if (idObj != null) { + ids.add(idObj.toString()); + } + } + return ids; + } + + private SolrDocumentList createMockDocumentList() { + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(0); + documents.setStart(0); + return documents; + } + + private SolrDocumentList createMockDocumentListWithData() { + SolrDocumentList documents = new SolrDocumentList(); + documents.setNumFound(2); + documents.setStart(0); + documents.setMaxScore(1.0f); + SolrDocument doc1 = new SolrDocument(); + doc1.setField("id", "book001"); + doc1.setField("name", "Spring Boot in Action"); + doc1.setField("author_ss", List.of("Craig Walls")); + doc1.setField("price", 39.99); + doc1.setField("genre_s", "technology"); + documents.add(doc1); + SolrDocument doc2 = new SolrDocument(); + doc2.setField("id", "book002"); + doc2.setField("name", "Effective Java"); + doc2.setField("author_ss", List.of("Joshua Bloch")); + doc2.setField("price", 44.99); + doc2.setField("genre_s", "technology"); + documents.add(doc2); + return documents; + } + + private List createMockFacetFields() { + FacetField genreFacet = new FacetField("genre_s"); + genreFacet.add("technology", 5); + genreFacet.add("fiction", 3); + FacetField authorFacet = new FacetField("author_ss"); + authorFacet.add("Craig Walls", 2); + authorFacet.add("Joshua Bloch", 1); + return List.of(genreFacet, authorFacet); + } } From 23b016880c6034efabcd1a9dc2e3411dd3322502 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Tue, 16 Dec 2025 17:05:10 -0500 Subject: [PATCH 2/6] test: disable tests requiring testcontainers when docker is not available --- .../java/org/apache/solr/mcp/server/config/SolrConfigTest.java | 2 ++ .../server/containerization/DockerImageHttpIntegrationTest.java | 2 +- .../containerization/DockerImageStdioIntegrationTest.java | 2 +- .../apache/solr/mcp/server/indexing/IndexingServiceTest.java | 2 ++ .../mcp/server/metadata/CollectionServiceIntegrationTest.java | 2 ++ .../solr/mcp/server/metadata/SchemaServiceIntegrationTest.java | 2 ++ .../org/apache/solr/mcp/server/search/SearchServiceTest.java | 2 ++ 7 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index 9423368..342ddd0 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -28,9 +28,11 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.testcontainers.containers.SolrContainer; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @Import(TestcontainersConfiguration.class) +@Testcontainers(disabledWithoutDocker = true) class SolrConfigTest { @Autowired diff --git a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java index e9b068f..45863c6 100644 --- a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java @@ -87,7 +87,7 @@ * designed to run separately from regular unit tests using the * {@code dockerIntegrationTest} Gradle task. */ -@Testcontainers +@Testcontainers(disabledWithoutDocker = true) @Tag("docker-integration") class DockerImageHttpIntegrationTest { diff --git a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java index 832aaee..96a8a6a 100644 --- a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java @@ -77,7 +77,7 @@ * designed to run separately from regular unit tests using the * {@code dockerIntegrationTest} Gradle task. */ -@Testcontainers +@Testcontainers(disabledWithoutDocker = true) @Tag("docker-integration") class DockerImageStdioIntegrationTest { diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java index ae05a10..c0bec69 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java @@ -47,9 +47,11 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.testcontainers.containers.SolrContainer; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @Import(TestcontainersConfiguration.class) +@Testcontainers(disabledWithoutDocker = true) class IndexingServiceTest { private static boolean initialized = false; diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java index 0266661..0fa9684 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java @@ -27,9 +27,11 @@ 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; @SpringBootTest @Import(TestcontainersConfiguration.class) +@Testcontainers(disabledWithoutDocker = true) class CollectionServiceIntegrationTest { private static final String TEST_COLLECTION = "test_collection"; diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java index 164f92f..020162f 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java @@ -27,6 +27,7 @@ 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 test suite for SchemaService using real Solr containers. Tests @@ -34,6 +35,7 @@ */ @SpringBootTest @Import(TestcontainersConfiguration.class) +@Testcontainers(disabledWithoutDocker = true) class SchemaServiceIntegrationTest { @Autowired diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java index a822cb5..ab336bd 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java @@ -42,6 +42,7 @@ 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; /** * Combined tests for SearchService: integration + unit (mocked SolrClient) in @@ -49,6 +50,7 @@ */ @SpringBootTest @Import(TestcontainersConfiguration.class) +@Testcontainers(disabledWithoutDocker = true) class SearchServiceTest { // ===== Integration test context ===== From b9a84c66cff4a4723c4df534b1bedc208f0a1f6f Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sat, 8 Nov 2025 23:17:36 -0500 Subject: [PATCH 3/6] docs: clarify ASF-compliant release process; fix nightly image naming and nightlies path; mark Docker images as convenience binaries; update MCP label snippet; extend release checklist and policy notes --- .github/workflows/nightly-build.yml | 54 ------ .github/workflows/release-publish.yml | 156 ++-------------- DOCKER_PUBLISHING.md | 252 ++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 194 deletions(-) create mode 100644 DOCKER_PUBLISHING.md diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 2edee1f..72a3374 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -13,50 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# ╔═══════════════════════════════════════════════════════════════════════════╗ -# ║ NIGHTLY BUILD WORKFLOW ║ -# ║ (Latest Unstable Builds) ║ -# ╚═══════════════════════════════════════════════════════════════════════════╝ -# -# PURPOSE: Automated nightly builds for testing latest changes -# -# WHEN TO USE: -# ----------- -# ✅ Automatic daily at 2 AM UTC -# ✅ For testing latest main branch changes -# ✅ Provides unstable/preview builds -# ✅ Publishes to apache/solr-mcp-nightly -# ❌ DO NOT use for production releases -# -# COMPARISON WITH OTHER WORKFLOWS: -# -------------------------------- -# nightly-build.yml (THIS FILE): -# - Purpose: Nightly builds -# - Trigger: Scheduled (2 AM UTC) -# - Docker Hub: apache/solr-mcp-nightly -# - Stability: Unstable/preview -# - Use for: Testing latest changes -# -# build-and-publish.yml: -# - Purpose: Development CI/CD -# - Trigger: Automatic (push/PR) -# - Docker Hub: Personal namespace -# - Use for: Daily development work -# -# release-publish.yml: -# - Purpose: Official ASF releases -# - Trigger: Manual (after vote) -# - Docker Hub: apache/solr-mcp -# - Stability: Stable/production -# - Use for: Production releases -# -# atr-release.yml: -# - Purpose: Future ATR automation -# - Status: Blocked (needs automated signing) -# - Use for: When ATR is ready -# -# ──────────────────────────────────────────────────────────────────────────── -# # Nightly Build Workflow for Apache Solr MCP # =========================================== # @@ -75,25 +31,18 @@ name: Nightly Build -# Triggers for the workflow -# - schedule: runs automatically via cron at a fixed time (02:00 UTC daily) -# - workflow_dispatch: allow maintainers to run the workflow manually and pass inputs on: schedule: # Run at 2 AM UTC every day - cron: '0 2 * * *' workflow_dispatch: # Allow manual trigger inputs: - # Optional input to skip Docker publishing if you only want to build artifacts skip_docker: description: 'Skip Docker publishing' required: false type: boolean default: false -# Environment variables used by steps below -# - JAVA_VERSION: selects the JDK version used to build and run Gradle -# - JAVA_DISTRIBUTION: selects the vendor (Temurin = Eclipse Adoptium) env: JAVA_VERSION: '25' JAVA_DISTRIBUTION: 'temurin' @@ -103,9 +52,6 @@ jobs: name: Nightly Build and Publish runs-on: ubuntu-latest - # Permissions required by this job: - # - contents:write → needed to create GitHub pre-releases and upload assets - # - packages:write → needed when pushing container images to registries permissions: contents: write packages: write diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 397d4a8..fcbf6a5 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -13,53 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# ╔═══════════════════════════════════════════════════════════════════════════╗ -# ║ OFFICIAL ASF RELEASE WORKFLOW ║ -# ║ (Production Releases Only) ║ -# ╚═══════════════════════════════════════════════════════════════════════════╝ -# -# PURPOSE: Publish official Apache releases after successful ASF vote -# -# WHEN TO USE: -# ----------- -# ✅ AFTER 72-hour ASF vote passes with 3+ PMC votes -# ✅ For official production releases only -# ✅ Publishes to apache/solr-mcp (official namespace) -# ✅ Updates MCP Registry -# ❌ DO NOT use for development builds (use build-and-publish.yml) -# ❌ DO NOT run before vote completes -# -# COMPARISON WITH OTHER WORKFLOWS: -# -------------------------------- -# release-publish.yml (THIS FILE): -# - Purpose: Official ASF releases -# - Trigger: Manual (after vote) -# - Docker Hub: apache/solr-mcp -# - MCP Registry: ✅ Yes -# - ASF Vote: Required (72 hours) -# - Use for: Production releases -# -# build-and-publish.yml: -# - Purpose: Development CI/CD -# - Trigger: Automatic (push/PR) -# - Docker Hub: Personal namespace -# - ASF Vote: Not required -# - Use for: Daily development work -# -# nightly-build.yml: -# - Purpose: Nightly builds -# - Trigger: Scheduled (2 AM UTC) -# - Docker Hub: apache/solr-mcp-nightly -# - Use for: Latest unstable builds -# -# atr-release.yml: -# - Purpose: Future ATR automation -# - Trigger: Manual (after prerequisites) -# - Status: Blocked (needs automated signing) -# - Use for: When ATR is ready -# -# ──────────────────────────────────────────────────────────────────────────── -# # Official Release Publishing Workflow # ===================================== # @@ -97,37 +50,27 @@ name: Release Publish -# Trigger this workflow manually from the GitHub UI and capture structured inputs -# - We require the GA version (e.g., 1.0.0) and the RC tag suffix (e.g., rc1) -# - Optional inputs help document the vote thread and enable experimental signing on: workflow_dispatch: inputs: - # Semantic version of the approved release (no -rc suffix) release_version: description: 'Release version (e.g., 1.0.0)' required: true type: string - # Which release candidate was approved (used to check out the exact tag) release_candidate: description: 'Release candidate number (e.g., rc1, rc2)' required: true type: string - # Link to the public ASF vote thread for traceability in the summary vote_thread_url: description: 'URL to the vote thread (for documentation)' required: false type: string - # If true, attempt to use ASF Infra code-signing (placeholder; requires coordination) sign_with_asf_infra: description: 'Use ASF code signing infrastructure' required: false type: boolean default: false -# Global environment settings used across jobs -# - JAVA_VERSION: version of JDK used to build the project -# - JAVA_DISTRIBUTION: OpenJDK distribution to install via actions/setup-java env: JAVA_VERSION: '25' JAVA_DISTRIBUTION: 'temurin' @@ -141,14 +84,11 @@ jobs: proceed: ${{ steps.validation.outputs.proceed }} steps: - # Step: Check out the exact RC tag that was approved (e.g., v1.0.0-rc1) - name: Checkout code uses: actions/checkout@v4 with: ref: "v${{ inputs.release_version }}-${{ inputs.release_candidate }}" - # Step: Validate that the supplied tag exists in the repo - # - Uses `git rev-parse` to resolve the tag; sets an output flag to gate downstream jobs - name: Validate release tag exists id: validation run: | @@ -162,7 +102,6 @@ jobs: exit 1 fi - # Optional: Document the vote approval in the Actions summary for traceability - name: Document vote approval if: ${{ inputs.vote_thread_url != '' }} run: | @@ -199,8 +138,7 @@ jobs: - name: Update version in build.gradle.kts run: | - # Ensure the Gradle project version matches the GA version (removes any -SNAPSHOT) - # This keeps image tags and any generated artifacts consistent with the voted release + # Update the version to remove SNAPSHOT sed -i 's/version = ".*"/version = "${{ inputs.release_version }}"/' build.gradle.kts - name: Build project @@ -336,86 +274,24 @@ jobs: echo "4. Tweet about the release (optional)" >> $GITHUB_STEP_SUMMARY publish-mcp-registry: - # Job: Publish to MCP Registry so MCP clients can discover the server version released above - # ---------------------------------------------------------------------------- - # Purpose: - # - Use the official MCP Publisher CLI to publish this server's metadata to - # the Model Context Protocol Registry. - # - Runs only after Docker images for the voted source release have been pushed. - # - # Why here (and not a separate workflow): - # - Keeps the post-vote release actions in one place, reducing the chance of - # publishing registry entries that don't correspond to an approved release. name: Publish to MCP Registry runs-on: ubuntu-latest - needs: publish-docker # Wait for Docker images to be published successfully + needs: publish-docker if: ${{ needs.validate-release.outputs.proceed == 'true' }} - # Permissions required for OIDC-based auth to the MCP Registry and read access - permissions: - id-token: write # Required for GitHub OIDC login in mcp-publisher - contents: read # Read repo files (e.g., server.json) - steps: - # Checkout the exact tag that was released so server.json matches the release - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: "v${{ inputs.release_version }}-${{ inputs.release_candidate }}" - - # Determine the semantic version to publish to MCP (no RC suffix) - - name: Prepare version for MCP - id: mcp_version - run: | - # For MCP, we advertise the GA version (e.g., 1.2.3) - echo "version=${{ inputs.release_version }}" >> $GITHUB_OUTPUT - - # Update server.json with the release version to ensure consistency - - name: Update server.json version - run: | - VERSION="${{ steps.mcp_version.outputs.version }}" - # Update the top-level server version (e.g., 1.2.3) - jq --arg v "$VERSION" '.version = $v' server.json > server.json.tmp - # Update package version (uses -SNAPSHOT suffix for image/JAR alignment if applicable) - jq --arg v "$VERSION-SNAPSHOT" '.packages[0].version = $v' server.json.tmp > server.json - rm server.json.tmp - # Show the final server.json for auditing - cat server.json - - # Download the MCP Publisher CLI from its latest GitHub release - - name: Download MCP Publisher - run: | - curl -L https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher-linux-amd64.tar.gz | tar xz - chmod +x mcp-publisher - ./mcp-publisher --version - - # Authenticate to the MCP Registry using GitHub OIDC (no static secrets) - - name: Authenticate with MCP Registry (GitHub OIDC) - run: | - ./mcp-publisher login github-oidc - - # Publish this server to the MCP Registry - - name: Publish to MCP Registry + - name: Register with MCP Registry run: | - ./mcp-publisher publish - - # Verify publication by querying the public registry API - - name: Verify publication - run: | - echo "Waiting 10 seconds for registry to update..." - sleep 10 - echo "Querying registry for published server..." - curl "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.apache/solr-mcp" | jq . - - # Summarize MCP publication in the GitHub Actions job summary - - name: Summary - run: | - echo "### MCP Server Published Successfully! :rocket:" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Server Name:** io.github.apache/solr-mcp" >> $GITHUB_STEP_SUMMARY - echo "**Version:** ${{ steps.mcp_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**Docker Image:** apache/solr-mcp:${{ inputs.release_version }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The server is now discoverable in the MCP Registry!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Registry URL:** https://registry.modelcontextprotocol.io/v0/servers?search=io.github.apache/solr-mcp" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + # Register the new release with the MCP registry + # This makes the server discoverable by MCP clients + + echo "Registering apache/solr-mcp:${{ inputs.release_version }} with MCP Registry" + + # The MCP registry would need to be notified about: + # - Docker image: apache/solr-mcp:${{ inputs.release_version }} + # - Capabilities: Search, indexing, query, admin operations + # - Version: ${{ inputs.release_version }} + # - Documentation URL: https://github.com/${{ github.repository }} + + # This is a placeholder - actual implementation depends on MCP registry API + echo "MCP Registry notification would happen here" \ No newline at end of file diff --git a/DOCKER_PUBLISHING.md b/DOCKER_PUBLISHING.md new file mode 100644 index 0000000..ae09638 --- /dev/null +++ b/DOCKER_PUBLISHING.md @@ -0,0 +1,252 @@ +# Docker Publishing Guide for Apache Solr MCP + +This guide documents the Docker image publishing process for Apache Solr MCP, including nightly builds, release candidates, and official releases. + +## Overview + +The Solr MCP project publishes Docker images to multiple registries: + +1. **GitHub Container Registry (GHCR)**: `ghcr.io/apache/solr-mcp` +2. **Docker Hub Official**: `apache/solr-mcp` (requires Apache PMC credentials) +3. **Docker Hub Nightly**: `apache/solr-mcp-nightly` (for pre-release builds) + +## Build System + +The project uses **Jib** (Google's containerization plugin) for building Docker images: +- No Docker daemon required for building +- Multi-platform support (linux/amd64 and linux/arm64) +- Optimized layering for faster deployments +- Reproducible builds + +## Publishing Workflows + +### 1. Development Builds (Per-merge to GHCR) + +Currently, this repository does not define an automated workflow for per-merge dev images. The recommended practice is to publish per-merge or ad-hoc development images to GHCR (not Docker Hub under `apache/`). This keeps the `apache/` namespace reserved for nightlies and voted releases. + +- Suggested images (if/when automated): + - `ghcr.io/{owner}/solr-mcp:VERSION-SNAPSHOT-SHA` + - `ghcr.io/{owner}/solr-mcp:latest` +- No ASF vote required for dev images (they are not releases and must be clearly marked as such). + +### 2. Nightly Builds + +**Workflow**: `.github/workflows/nightly-build.yml` + +- **Schedule**: Daily at 2 AM UTC +- **Images Published**: + - `apache/solr-mcp-nightly:nightly-YYYYMMDD-SHA` + - `apache/solr-mcp-nightly:latest-nightly` +- **Artifacts**: + - Source tarball to `https://nightlies.apache.org/solr/mcp/` + - GitHub pre-release with build artifacts +- **No ASF vote required** + +### 3. Official Releases + +**Workflow**: `.github/workflows/release-publish.yml` + +- **Trigger**: Manual (after ASF vote passes) +- **Prerequisites**: + - 72-hour ASF voting period completed + - Successful PMC vote + - Release artifacts signed by Release Manager +- **Images Published**: + - `apache/solr-mcp:VERSION` + - `apache/solr-mcp:latest` + - `apache/solr-mcp:MAJOR` + - `apache/solr-mcp:MAJOR.MINOR` + - `ghcr.io/apache/solr-mcp:VERSION` + +## ASF Policy Notes + +- The authoritative ASF release is the signed source distribution published to the ASF distribution system (`dist.apache.org` / `downloads.apache.org` mirrors). Docker images and other binaries are considered convenience binaries and must be built from the voted source, but they are not the release of record. +- Releases require a minimum 72-hour vote with at least three +1 binding PMC votes. Only after the vote passes may convenience binaries (e.g., Docker images) be published. +- Release artifacts must be signed by the Release Manager using their PGP key that is present in the project `KEYS` file. Automated signing via ASF Infra may be possible but must be explicitly arranged with INFRA; manual RM signing remains the baseline. +- Nightly and per-merge builds are allowed as non-release artifacts. They must be clearly marked as such and must not be uploaded to the ASF release distribution system. + +## ASF Release Process + +### Step 1: Create Release Candidate + +```bash +# Tag the release candidate +git tag -s v1.0.0-rc1 -m "Release candidate 1 for version 1.0.0" + +# Create source distribution +tar czf solr-mcp-1.0.0-rc1-src.tar.gz --exclude='.git' --exclude='build' . + +# Sign the release +gpg --armor --detach-sign solr-mcp-1.0.0-rc1-src.tar.gz + +# Generate checksums +sha512sum solr-mcp-1.0.0-rc1-src.tar.gz > solr-mcp-1.0.0-rc1-src.tar.gz.sha512 +``` + +### Step 2: Stage for Voting + +1. Upload release candidate to Apache staging area +2. Send vote email to dev@solr.apache.org +3. Wait for 72-hour voting period + +### Step 3: After Successful Vote + +Trigger the release publish workflow: + +```bash +# Via GitHub UI +# Go to Actions → Release Publish → Run workflow + +# Fill in: +# - Release version: 1.0.0 +# - Release candidate: rc1 +# - Vote thread URL: https://lists.apache.org/... +# - Use ASF code signing: true (if available) +``` + +## Building Docker Images Locally + +### Using Gradle with Jib + +```bash +# Build to local Docker daemon +./gradlew jibDockerBuild + +# Build and push to registry +./gradlew jib -Djib.to.image=myregistry/solr-mcp:my-tag \ + -Djib.to.auth.username=USERNAME \ + -Djib.to.auth.password=TOKEN +``` + +### Multi-platform Build + +The Jib configuration in `build.gradle.kts` automatically builds for: +- `linux/amd64` (x86_64) +- `linux/arm64` (Apple Silicon, ARM servers) + +## Registry Authentication + +### GitHub Container Registry + +GHCR uses the built-in `GITHUB_TOKEN` in workflows. For local pushing: + +```bash +echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin +``` + +### Docker Hub + +For Apache official images, set these secrets in GitHub: +- `DOCKERHUB_APACHE_USERNAME` +- `DOCKERHUB_APACHE_TOKEN` + +Create token at: https://hub.docker.com/settings/security + +### Apache Nightlies + +Requires Apache committer credentials: +- `APACHE_NIGHTLIES_USER` +- `APACHE_NIGHTLIES_KEY` + +## MCP Registry Integration + +The Docker image includes MCP metadata labels: + +```dockerfile +LABEL io.modelcontextprotocol.server.name="io.github.apache/solr-mcp" +``` + +After release, the image should be registered with the MCP registry for discoverability. + +## Security Considerations + +### Code Signing + +For official releases, we aim to use ASF's code signing infrastructure: + +1. Contact ASF INFRA for setup requirements +2. Enable in release workflow with `sign_with_asf_infra: true` +3. Signatures will be automatically applied to artifacts + +### Image Scanning + +All images should be scanned for vulnerabilities: + +```bash +# Using Docker Scout +docker scout cves apache/solr-mcp:latest + +# Using Trivy +trivy image apache/solr-mcp:latest +``` + +### SBOM Generation + +Software Bill of Materials can be generated: + +```bash +# If CycloneDX is configured +./gradlew cyclonedxBom +``` + +## Troubleshooting + +### Jib Build Issues + +If Jib can't find Docker: + +```bash +# Set Docker executable path +export DOCKER_EXECUTABLE=/usr/local/bin/docker +./gradlew jibDockerBuild +``` + +### Authentication Failures + +For registry authentication issues: + +1. Verify credentials are correct +2. Check token permissions (write:packages for GHCR) +3. Ensure tokens haven't expired + +### Multi-platform Build Failures + +If ARM64 builds fail: + +1. Ensure base image supports ARM64 +2. Check that all dependencies are multi-platform compatible +3. Consider using emulation for testing: `docker run --platform linux/arm64` + +## Versioning Strategy + +- **Main branch**: `VERSION-SNAPSHOT-SHA` (e.g., `0.0.1-SNAPSHOT-a1b2c3d`) +- **Nightly**: `nightly-YYYYMMDD-SHA` (e.g., `nightly-20240115-a1b2c3d`) +- **Release**: Semantic versioning `MAJOR.MINOR.PATCH` (e.g., `1.0.0`) + +## Release Checklist + +- [ ] Create release branch +- [ ] Update version in `build.gradle.kts` +- [ ] Update CHANGELOG.md +- [ ] Tag release candidate +- [ ] Create source distribution +- [ ] Sign artifacts with GPG (RM key listed in project `KEYS`) +- [ ] Generate checksums (.sha512) +- [ ] Upload to Apache staging +- [ ] Send vote email (minimum 72 hours; need ≥3 +1 PMC binding votes) +- [ ] After vote passes: publish signed source release to dist.apache.org (mirrors) +- [ ] Verify `KEYS` is up to date and signatures/sha512 verify +- [ ] Trigger the Release Publish workflow to push Docker images (convenience binaries) +- [ ] Verify Docker images are published to Docker Hub and GHCR +- [ ] Update MCP registry (optional) +- [ ] Announce release (dev@ / user@ / website) +- [ ] Close GitHub milestone + +## Contact + +For questions about the release process: +- Apache Solr Dev List: dev@solr.apache.org +- ASF INFRA: https://infra.apache.org/ + +For Docker/Jib specific issues: +- Create issue at: https://github.com/apache/solr-mcp/issues \ No newline at end of file From 29df18790a886a5b7b78351e12a27634b523b27b Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sat, 8 Nov 2025 23:44:02 -0500 Subject: [PATCH 4/6] docs: enhance GitHub workflows with detailed comments for release management, nightly builds, and publishing steps --- .github/workflows/nightly-build.yml | 10 +++ .github/workflows/release-publish.yml | 109 ++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 72a3374..c05413d 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -31,18 +31,25 @@ name: Nightly Build +# Triggers for the workflow +# - schedule: runs automatically via cron at a fixed time (02:00 UTC daily) +# - workflow_dispatch: allow maintainers to run the workflow manually and pass inputs on: schedule: # Run at 2 AM UTC every day - cron: '0 2 * * *' workflow_dispatch: # Allow manual trigger inputs: + # Optional input to skip Docker publishing if you only want to build artifacts skip_docker: description: 'Skip Docker publishing' required: false type: boolean default: false +# Environment variables used by steps below +# - JAVA_VERSION: selects the JDK version used to build and run Gradle +# - JAVA_DISTRIBUTION: selects the vendor (Temurin = Eclipse Adoptium) env: JAVA_VERSION: '25' JAVA_DISTRIBUTION: 'temurin' @@ -52,6 +59,9 @@ jobs: name: Nightly Build and Publish runs-on: ubuntu-latest + # Permissions required by this job: + # - contents:write → needed to create GitHub pre-releases and upload assets + # - packages:write → needed when pushing container images to registries permissions: contents: write packages: write diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index fcbf6a5..e00de95 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -50,27 +50,37 @@ name: Release Publish +# Trigger this workflow manually from the GitHub UI and capture structured inputs +# - We require the GA version (e.g., 1.0.0) and the RC tag suffix (e.g., rc1) +# - Optional inputs help document the vote thread and enable experimental signing on: workflow_dispatch: inputs: + # Semantic version of the approved release (no -rc suffix) release_version: description: 'Release version (e.g., 1.0.0)' required: true type: string + # Which release candidate was approved (used to check out the exact tag) release_candidate: description: 'Release candidate number (e.g., rc1, rc2)' required: true type: string + # Link to the public ASF vote thread for traceability in the summary vote_thread_url: description: 'URL to the vote thread (for documentation)' required: false type: string + # If true, attempt to use ASF Infra code-signing (placeholder; requires coordination) sign_with_asf_infra: description: 'Use ASF code signing infrastructure' required: false type: boolean default: false +# Global environment settings used across jobs +# - JAVA_VERSION: version of JDK used to build the project +# - JAVA_DISTRIBUTION: OpenJDK distribution to install via actions/setup-java env: JAVA_VERSION: '25' JAVA_DISTRIBUTION: 'temurin' @@ -84,11 +94,14 @@ jobs: proceed: ${{ steps.validation.outputs.proceed }} steps: + # Step: Check out the exact RC tag that was approved (e.g., v1.0.0-rc1) - name: Checkout code uses: actions/checkout@v4 with: ref: "v${{ inputs.release_version }}-${{ inputs.release_candidate }}" + # Step: Validate that the supplied tag exists in the repo + # - Uses `git rev-parse` to resolve the tag; sets an output flag to gate downstream jobs - name: Validate release tag exists id: validation run: | @@ -102,6 +115,7 @@ jobs: exit 1 fi + # Optional: Document the vote approval in the Actions summary for traceability - name: Document vote approval if: ${{ inputs.vote_thread_url != '' }} run: | @@ -138,7 +152,8 @@ jobs: - name: Update version in build.gradle.kts run: | - # Update the version to remove SNAPSHOT + # Ensure the Gradle project version matches the GA version (removes any -SNAPSHOT) + # This keeps image tags and any generated artifacts consistent with the voted release sed -i 's/version = ".*"/version = "${{ inputs.release_version }}"/' build.gradle.kts - name: Build project @@ -274,24 +289,86 @@ jobs: echo "4. Tweet about the release (optional)" >> $GITHUB_STEP_SUMMARY publish-mcp-registry: + # Job: Publish to MCP Registry so MCP clients can discover the server version released above + # ---------------------------------------------------------------------------- + # Purpose: + # - Use the official MCP Publisher CLI to publish this server's metadata to + # the Model Context Protocol Registry. + # - Runs only after Docker images for the voted source release have been pushed. + # + # Why here (and not a separate workflow): + # - Keeps the post-vote release actions in one place, reducing the chance of + # publishing registry entries that don't correspond to an approved release. name: Publish to MCP Registry runs-on: ubuntu-latest - needs: publish-docker + needs: publish-docker # Wait for Docker images to be published successfully if: ${{ needs.validate-release.outputs.proceed == 'true' }} + # Permissions required for OIDC-based auth to the MCP Registry and read access + permissions: + id-token: write # Required for GitHub OIDC login in mcp-publisher + contents: read # Read repo files (e.g., server.json) + steps: - - name: Register with MCP Registry + # Checkout the exact tag that was released so server.json matches the release + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: "v${{ inputs.release_version }}-${{ inputs.release_candidate }}" + + # Determine the semantic version to publish to MCP (no RC suffix) + - name: Prepare version for MCP + id: mcp_version + run: | + # For MCP, we advertise the GA version (e.g., 1.2.3) + echo "version=${{ inputs.release_version }}" >> $GITHUB_OUTPUT + + # Update server.json with the release version to ensure consistency + - name: Update server.json version + run: | + VERSION="${{ steps.mcp_version.outputs.version }}" + # Update the top-level server version (e.g., 1.2.3) + jq --arg v "$VERSION" '.version = $v' server.json > server.json.tmp + # Update package version (uses -SNAPSHOT suffix for image/JAR alignment if applicable) + jq --arg v "$VERSION-SNAPSHOT" '.packages[0].version = $v' server.json.tmp > server.json + rm server.json.tmp + # Show the final server.json for auditing + cat server.json + + # Download the MCP Publisher CLI from its latest GitHub release + - name: Download MCP Publisher + run: | + curl -L https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher-linux-amd64.tar.gz | tar xz + chmod +x mcp-publisher + ./mcp-publisher --version + + # Authenticate to the MCP Registry using GitHub OIDC (no static secrets) + - name: Authenticate with MCP Registry (GitHub OIDC) + run: | + ./mcp-publisher login github-oidc + + # Publish this server to the MCP Registry + - name: Publish to MCP Registry run: | - # Register the new release with the MCP registry - # This makes the server discoverable by MCP clients - - echo "Registering apache/solr-mcp:${{ inputs.release_version }} with MCP Registry" - - # The MCP registry would need to be notified about: - # - Docker image: apache/solr-mcp:${{ inputs.release_version }} - # - Capabilities: Search, indexing, query, admin operations - # - Version: ${{ inputs.release_version }} - # - Documentation URL: https://github.com/${{ github.repository }} - - # This is a placeholder - actual implementation depends on MCP registry API - echo "MCP Registry notification would happen here" \ No newline at end of file + ./mcp-publisher publish + + # Verify publication by querying the public registry API + - name: Verify publication + run: | + echo "Waiting 10 seconds for registry to update..." + sleep 10 + echo "Querying registry for published server..." + curl "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.apache/solr-mcp" | jq . + + # Summarize MCP publication in the GitHub Actions job summary + - name: Summary + run: | + echo "### MCP Server Published Successfully! :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Server Name:** io.github.apache/solr-mcp" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.mcp_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Docker Image:** apache/solr-mcp:${{ inputs.release_version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The server is now discoverable in the MCP Registry!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Registry URL:** https://registry.modelcontextprotocol.io/v0/servers?search=io.github.apache/solr-mcp" >> $GITHUB_STEP_SUMMARY \ No newline at end of file From e9b466311f4335224c17db3e4c7c528267d67ee5 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Wed, 12 Nov 2025 22:55:50 -0500 Subject: [PATCH 5/6] feat: Create atr-release.yml for Apache Trusted Releases --- .github/workflows/atr-release-test.yml | 8 +- .github/workflows/nightly-build.yml | 44 +++++ .github/workflows/release-publish.yml | 47 +++++ DOCKER_PUBLISHING.md | 252 ------------------------- 4 files changed, 96 insertions(+), 255 deletions(-) delete mode 100644 DOCKER_PUBLISHING.md diff --git a/.github/workflows/atr-release-test.yml b/.github/workflows/atr-release-test.yml index 5667d58..045c6b4 100644 --- a/.github/workflows/atr-release-test.yml +++ b/.github/workflows/atr-release-test.yml @@ -227,9 +227,10 @@ jobs: echo "Total size: $(du -sh build/distributions | cut -f1)" - name: Upload artifacts to ATR (Real) - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && secrets.ASF_USERNAME != '' }} uses: apache/tooling-actions/upload-to-atr@main with: + asf-uid: ${{ secrets.ASF_USERNAME }} project: ${{ env.ATR_PROJECT_NAME }} version: ${{ inputs.release_version }}-${{ inputs.release_candidate }} src: build/distributions @@ -237,7 +238,7 @@ jobs: ssh-port: 2222 - name: Upload artifacts for review - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 6.0.0 + uses: actions/upload-artifact@v3 with: name: test-release-artifacts-${{ inputs.release_version }}-${{ inputs.release_candidate }} path: build/distributions/ @@ -349,7 +350,7 @@ jobs: echo "----------------------------------------" - name: Resolve vote and announce on ATR (Real) - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && secrets.ASF_USERNAME != '' }} uses: apache/tooling-actions/release-on-atr@main with: version: ${{ inputs.release_version }} @@ -369,6 +370,7 @@ jobs: echo "✅ Dry run completed successfully" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "To test with real ATR (requires onboarding):" >> $GITHUB_STEP_SUMMARY + echo "1. Add ASF_USERNAME secret" >> $GITHUB_STEP_SUMMARY echo "2. Run workflow with dry_run=false" >> $GITHUB_STEP_SUMMARY else echo "✅ ATR test completed" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index c05413d..2edee1f 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -13,6 +13,50 @@ # See the License for the specific language governing permissions and # limitations under the License. +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ NIGHTLY BUILD WORKFLOW ║ +# ║ (Latest Unstable Builds) ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ +# +# PURPOSE: Automated nightly builds for testing latest changes +# +# WHEN TO USE: +# ----------- +# ✅ Automatic daily at 2 AM UTC +# ✅ For testing latest main branch changes +# ✅ Provides unstable/preview builds +# ✅ Publishes to apache/solr-mcp-nightly +# ❌ DO NOT use for production releases +# +# COMPARISON WITH OTHER WORKFLOWS: +# -------------------------------- +# nightly-build.yml (THIS FILE): +# - Purpose: Nightly builds +# - Trigger: Scheduled (2 AM UTC) +# - Docker Hub: apache/solr-mcp-nightly +# - Stability: Unstable/preview +# - Use for: Testing latest changes +# +# build-and-publish.yml: +# - Purpose: Development CI/CD +# - Trigger: Automatic (push/PR) +# - Docker Hub: Personal namespace +# - Use for: Daily development work +# +# release-publish.yml: +# - Purpose: Official ASF releases +# - Trigger: Manual (after vote) +# - Docker Hub: apache/solr-mcp +# - Stability: Stable/production +# - Use for: Production releases +# +# atr-release.yml: +# - Purpose: Future ATR automation +# - Status: Blocked (needs automated signing) +# - Use for: When ATR is ready +# +# ──────────────────────────────────────────────────────────────────────────── +# # Nightly Build Workflow for Apache Solr MCP # =========================================== # diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index e00de95..397d4a8 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -13,6 +13,53 @@ # See the License for the specific language governing permissions and # limitations under the License. +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ OFFICIAL ASF RELEASE WORKFLOW ║ +# ║ (Production Releases Only) ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ +# +# PURPOSE: Publish official Apache releases after successful ASF vote +# +# WHEN TO USE: +# ----------- +# ✅ AFTER 72-hour ASF vote passes with 3+ PMC votes +# ✅ For official production releases only +# ✅ Publishes to apache/solr-mcp (official namespace) +# ✅ Updates MCP Registry +# ❌ DO NOT use for development builds (use build-and-publish.yml) +# ❌ DO NOT run before vote completes +# +# COMPARISON WITH OTHER WORKFLOWS: +# -------------------------------- +# release-publish.yml (THIS FILE): +# - Purpose: Official ASF releases +# - Trigger: Manual (after vote) +# - Docker Hub: apache/solr-mcp +# - MCP Registry: ✅ Yes +# - ASF Vote: Required (72 hours) +# - Use for: Production releases +# +# build-and-publish.yml: +# - Purpose: Development CI/CD +# - Trigger: Automatic (push/PR) +# - Docker Hub: Personal namespace +# - ASF Vote: Not required +# - Use for: Daily development work +# +# nightly-build.yml: +# - Purpose: Nightly builds +# - Trigger: Scheduled (2 AM UTC) +# - Docker Hub: apache/solr-mcp-nightly +# - Use for: Latest unstable builds +# +# atr-release.yml: +# - Purpose: Future ATR automation +# - Trigger: Manual (after prerequisites) +# - Status: Blocked (needs automated signing) +# - Use for: When ATR is ready +# +# ──────────────────────────────────────────────────────────────────────────── +# # Official Release Publishing Workflow # ===================================== # diff --git a/DOCKER_PUBLISHING.md b/DOCKER_PUBLISHING.md deleted file mode 100644 index ae09638..0000000 --- a/DOCKER_PUBLISHING.md +++ /dev/null @@ -1,252 +0,0 @@ -# Docker Publishing Guide for Apache Solr MCP - -This guide documents the Docker image publishing process for Apache Solr MCP, including nightly builds, release candidates, and official releases. - -## Overview - -The Solr MCP project publishes Docker images to multiple registries: - -1. **GitHub Container Registry (GHCR)**: `ghcr.io/apache/solr-mcp` -2. **Docker Hub Official**: `apache/solr-mcp` (requires Apache PMC credentials) -3. **Docker Hub Nightly**: `apache/solr-mcp-nightly` (for pre-release builds) - -## Build System - -The project uses **Jib** (Google's containerization plugin) for building Docker images: -- No Docker daemon required for building -- Multi-platform support (linux/amd64 and linux/arm64) -- Optimized layering for faster deployments -- Reproducible builds - -## Publishing Workflows - -### 1. Development Builds (Per-merge to GHCR) - -Currently, this repository does not define an automated workflow for per-merge dev images. The recommended practice is to publish per-merge or ad-hoc development images to GHCR (not Docker Hub under `apache/`). This keeps the `apache/` namespace reserved for nightlies and voted releases. - -- Suggested images (if/when automated): - - `ghcr.io/{owner}/solr-mcp:VERSION-SNAPSHOT-SHA` - - `ghcr.io/{owner}/solr-mcp:latest` -- No ASF vote required for dev images (they are not releases and must be clearly marked as such). - -### 2. Nightly Builds - -**Workflow**: `.github/workflows/nightly-build.yml` - -- **Schedule**: Daily at 2 AM UTC -- **Images Published**: - - `apache/solr-mcp-nightly:nightly-YYYYMMDD-SHA` - - `apache/solr-mcp-nightly:latest-nightly` -- **Artifacts**: - - Source tarball to `https://nightlies.apache.org/solr/mcp/` - - GitHub pre-release with build artifacts -- **No ASF vote required** - -### 3. Official Releases - -**Workflow**: `.github/workflows/release-publish.yml` - -- **Trigger**: Manual (after ASF vote passes) -- **Prerequisites**: - - 72-hour ASF voting period completed - - Successful PMC vote - - Release artifacts signed by Release Manager -- **Images Published**: - - `apache/solr-mcp:VERSION` - - `apache/solr-mcp:latest` - - `apache/solr-mcp:MAJOR` - - `apache/solr-mcp:MAJOR.MINOR` - - `ghcr.io/apache/solr-mcp:VERSION` - -## ASF Policy Notes - -- The authoritative ASF release is the signed source distribution published to the ASF distribution system (`dist.apache.org` / `downloads.apache.org` mirrors). Docker images and other binaries are considered convenience binaries and must be built from the voted source, but they are not the release of record. -- Releases require a minimum 72-hour vote with at least three +1 binding PMC votes. Only after the vote passes may convenience binaries (e.g., Docker images) be published. -- Release artifacts must be signed by the Release Manager using their PGP key that is present in the project `KEYS` file. Automated signing via ASF Infra may be possible but must be explicitly arranged with INFRA; manual RM signing remains the baseline. -- Nightly and per-merge builds are allowed as non-release artifacts. They must be clearly marked as such and must not be uploaded to the ASF release distribution system. - -## ASF Release Process - -### Step 1: Create Release Candidate - -```bash -# Tag the release candidate -git tag -s v1.0.0-rc1 -m "Release candidate 1 for version 1.0.0" - -# Create source distribution -tar czf solr-mcp-1.0.0-rc1-src.tar.gz --exclude='.git' --exclude='build' . - -# Sign the release -gpg --armor --detach-sign solr-mcp-1.0.0-rc1-src.tar.gz - -# Generate checksums -sha512sum solr-mcp-1.0.0-rc1-src.tar.gz > solr-mcp-1.0.0-rc1-src.tar.gz.sha512 -``` - -### Step 2: Stage for Voting - -1. Upload release candidate to Apache staging area -2. Send vote email to dev@solr.apache.org -3. Wait for 72-hour voting period - -### Step 3: After Successful Vote - -Trigger the release publish workflow: - -```bash -# Via GitHub UI -# Go to Actions → Release Publish → Run workflow - -# Fill in: -# - Release version: 1.0.0 -# - Release candidate: rc1 -# - Vote thread URL: https://lists.apache.org/... -# - Use ASF code signing: true (if available) -``` - -## Building Docker Images Locally - -### Using Gradle with Jib - -```bash -# Build to local Docker daemon -./gradlew jibDockerBuild - -# Build and push to registry -./gradlew jib -Djib.to.image=myregistry/solr-mcp:my-tag \ - -Djib.to.auth.username=USERNAME \ - -Djib.to.auth.password=TOKEN -``` - -### Multi-platform Build - -The Jib configuration in `build.gradle.kts` automatically builds for: -- `linux/amd64` (x86_64) -- `linux/arm64` (Apple Silicon, ARM servers) - -## Registry Authentication - -### GitHub Container Registry - -GHCR uses the built-in `GITHUB_TOKEN` in workflows. For local pushing: - -```bash -echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin -``` - -### Docker Hub - -For Apache official images, set these secrets in GitHub: -- `DOCKERHUB_APACHE_USERNAME` -- `DOCKERHUB_APACHE_TOKEN` - -Create token at: https://hub.docker.com/settings/security - -### Apache Nightlies - -Requires Apache committer credentials: -- `APACHE_NIGHTLIES_USER` -- `APACHE_NIGHTLIES_KEY` - -## MCP Registry Integration - -The Docker image includes MCP metadata labels: - -```dockerfile -LABEL io.modelcontextprotocol.server.name="io.github.apache/solr-mcp" -``` - -After release, the image should be registered with the MCP registry for discoverability. - -## Security Considerations - -### Code Signing - -For official releases, we aim to use ASF's code signing infrastructure: - -1. Contact ASF INFRA for setup requirements -2. Enable in release workflow with `sign_with_asf_infra: true` -3. Signatures will be automatically applied to artifacts - -### Image Scanning - -All images should be scanned for vulnerabilities: - -```bash -# Using Docker Scout -docker scout cves apache/solr-mcp:latest - -# Using Trivy -trivy image apache/solr-mcp:latest -``` - -### SBOM Generation - -Software Bill of Materials can be generated: - -```bash -# If CycloneDX is configured -./gradlew cyclonedxBom -``` - -## Troubleshooting - -### Jib Build Issues - -If Jib can't find Docker: - -```bash -# Set Docker executable path -export DOCKER_EXECUTABLE=/usr/local/bin/docker -./gradlew jibDockerBuild -``` - -### Authentication Failures - -For registry authentication issues: - -1. Verify credentials are correct -2. Check token permissions (write:packages for GHCR) -3. Ensure tokens haven't expired - -### Multi-platform Build Failures - -If ARM64 builds fail: - -1. Ensure base image supports ARM64 -2. Check that all dependencies are multi-platform compatible -3. Consider using emulation for testing: `docker run --platform linux/arm64` - -## Versioning Strategy - -- **Main branch**: `VERSION-SNAPSHOT-SHA` (e.g., `0.0.1-SNAPSHOT-a1b2c3d`) -- **Nightly**: `nightly-YYYYMMDD-SHA` (e.g., `nightly-20240115-a1b2c3d`) -- **Release**: Semantic versioning `MAJOR.MINOR.PATCH` (e.g., `1.0.0`) - -## Release Checklist - -- [ ] Create release branch -- [ ] Update version in `build.gradle.kts` -- [ ] Update CHANGELOG.md -- [ ] Tag release candidate -- [ ] Create source distribution -- [ ] Sign artifacts with GPG (RM key listed in project `KEYS`) -- [ ] Generate checksums (.sha512) -- [ ] Upload to Apache staging -- [ ] Send vote email (minimum 72 hours; need ≥3 +1 PMC binding votes) -- [ ] After vote passes: publish signed source release to dist.apache.org (mirrors) -- [ ] Verify `KEYS` is up to date and signatures/sha512 verify -- [ ] Trigger the Release Publish workflow to push Docker images (convenience binaries) -- [ ] Verify Docker images are published to Docker Hub and GHCR -- [ ] Update MCP registry (optional) -- [ ] Announce release (dev@ / user@ / website) -- [ ] Close GitHub milestone - -## Contact - -For questions about the release process: -- Apache Solr Dev List: dev@solr.apache.org -- ASF INFRA: https://infra.apache.org/ - -For Docker/Jib specific issues: -- Create issue at: https://github.com/apache/solr-mcp/issues \ No newline at end of file From e65f1df2f8bbd333225ae04c3df092378f8b9bd4 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 8 Mar 2026 20:09:45 -0400 Subject: [PATCH 6/6] fix(quality): resolve SonarQube violations - S7467: Replace unused catch variable with _ in SolrConfigTest (Java 25) - S1602: Remove unnecessary curly braces from single-statement lambdas in IndexingServiceTest and SchemaServiceIntegrationTest Signed-off-by: Aditya Parikh Co-Authored-By: Claude Opus 4.6 Signed-off-by: adityamparikh --- .../mcp/server/config/SolrConfigTest.java | 10 ++++---- .../server/indexing/IndexingServiceTest.java | 24 +++++++------------ .../SchemaServiceIntegrationTest.java | 15 +++++------- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index 342ddd0..e4dfe36 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -90,7 +90,7 @@ void testUrlNormalization(String inputUrl, String expectedUrl) { // Clean up try { client.close(); - } catch (Exception e) { + } catch (Exception _) { // Ignore close errors in test } } @@ -109,7 +109,7 @@ void testUrlWithoutTrailingSlash() { try { client.close(); - } catch (Exception e) { + } catch (Exception _) { // Ignore close errors in test } } @@ -128,7 +128,7 @@ void testUrlWithTrailingSlashButNoSolrPath() { try { client.close(); - } catch (Exception e) { + } catch (Exception _) { // Ignore close errors in test } } @@ -147,7 +147,7 @@ void testUrlWithSolrPathButNoTrailingSlash() { try { client.close(); - } catch (Exception e) { + } catch (Exception _) { // Ignore close errors in test } } @@ -166,7 +166,7 @@ void testUrlAlreadyProperlyFormatted() { try { client.close(); - } catch (Exception e) { + } catch (Exception _) { // Ignore close errors in test } } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java index c0bec69..ea6dc80 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java @@ -802,9 +802,8 @@ void indexJsonDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateExcept when(indexingDocumentCreator.createSchemalessDocumentsFromJson(invalidJson)).thenThrow( new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Invalid JSON")); - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexJsonDocuments("test_collection", invalidJson); - }); + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, + () -> indexingService.indexJsonDocuments("test_collection", invalidJson)); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -830,9 +829,8 @@ void indexCsvDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateExcepti when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(invalidCsv)).thenThrow( new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Invalid CSV")); - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexCsvDocuments("test_collection", invalidCsv); - }); + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, + () -> indexingService.indexCsvDocuments("test_collection", invalidCsv)); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -858,9 +856,8 @@ void indexXmlDocuments_WhenParserConfigurationFails_ShouldPropagateException() t when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)).thenThrow( new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Parser error")); - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexXmlDocuments("test_collection", xml); - }); + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, + () -> indexingService.indexXmlDocuments("test_collection", xml)); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -872,9 +869,8 @@ void indexXmlDocuments_WhenSaxExceptionOccurs_ShouldPropagateException() throws .thenThrow(new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException( "SAX parsing error")); - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexXmlDocuments("test_collection", xml); - }); + assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, + () -> indexingService.indexXmlDocuments("test_collection", xml)); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -959,9 +955,7 @@ void indexDocuments_WhenCommitFails_ShouldPropagateException() throws Exception when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); when(solrClient.commit("test_collection")).thenThrow(new IOException("Commit failed")); - assertThrows(IOException.class, () -> { - indexingService.indexDocuments("test_collection", docs); - }); + assertThrows(IOException.class, () -> indexingService.indexDocuments("test_collection", docs)); verify(solrClient).add(eq("test_collection"), any(Collection.class)); verify(solrClient).commit("test_collection"); } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java index 020162f..f3a8c06 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java @@ -85,25 +85,22 @@ void testGetSchema_ValidCollection() throws Exception { @Test void testGetSchema_InvalidCollection() { // When/Then - assertThrows(Exception.class, () -> { - schemaService.getSchema("non_existent_collection_12345"); - }, "Getting schema for non-existent collection should throw exception"); + assertThrows(Exception.class, () -> schemaService.getSchema("non_existent_collection_12345"), + "Getting schema for non-existent collection should throw exception"); } @Test void testGetSchema_NullCollection() { // When/Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(null); - }, "Getting schema with null collection should throw exception"); + assertThrows(Exception.class, () -> schemaService.getSchema(null), + "Getting schema with null collection should throw exception"); } @Test void testGetSchema_EmptyCollection() { // When/Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(""); - }, "Getting schema with empty collection should throw exception"); + assertThrows(Exception.class, () -> schemaService.getSchema(""), + "Getting schema with empty collection should throw exception"); } @Test