From 96b24d1b4a1616ffefc1e47023c9e86f0abba075 Mon Sep 17 00:00:00 2001 From: Karen X Date: Wed, 8 Oct 2025 20:32:41 +0000 Subject: [PATCH 1/2] [GRPC] Return detailed error for GRPC error response Signed-off-by: Karen X --- CHANGELOG.md | 1 + .../SearchRequestActionListener.java | 2 +- .../grpc/services/DocumentServiceImpl.java | 2 +- .../grpc/services/SearchServiceImpl.java | 2 +- .../transport/grpc/util/GrpcErrorHandler.java | 81 +++++++-- .../grpc/util/GrpcErrorHandlerTests.java | 165 +++++++++++++----- 6 files changed, 189 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d087c7e8d3371..f050227c82d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Harden the circuit breaker and failure handle logic in query result consumer ([#19396](https://github.com/opensearch-project/OpenSearch/pull/19396)) - Add streaming cardinality aggregator ([#19484](https://github.com/opensearch-project/OpenSearch/pull/19484)) - Disable request cache for streaming aggregation queries ([#19520](https://github.com/opensearch-project/OpenSearch/pull/19520)) +- Return full error for GRPC error response ([#19568](https://github.com/opensearch-project/OpenSearch/pull/19568)) ### Changed - Refactor `if-else` chains to use `Java 17 pattern matching switch expressions`(([#18965](https://github.com/opensearch-project/OpenSearch/pull/18965)) diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/SearchRequestActionListener.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/SearchRequestActionListener.java index 7712d85a3a217..ec20b15bde263 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/SearchRequestActionListener.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/SearchRequestActionListener.java @@ -54,7 +54,7 @@ public void onResponse(SearchResponse response) { @Override public void onFailure(Exception e) { - logger.error("SearchRequestActionListener failed to process search request: " + e.getMessage()); + logger.debug("SearchRequestActionListener failed to process search request: " + e.getMessage()); StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); responseObserver.onError(grpcError); } diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java index 873bad5e69e4f..e1348bc78961f 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java @@ -48,7 +48,7 @@ public void bulk(org.opensearch.protobufs.BulkRequest request, StreamObserver INVALID_ARGUMENT via RestToGrpcStatusConverter assertEquals(Status.INVALID_ARGUMENT.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("[Test exception]")); + // Uses ExceptionsHelper.summaryMessage() format + XContent details + assertTrue(result.getMessage().contains("OpenSearchException[Test exception]")); + assertTrue(result.getMessage().contains("details=")); + assertTrue(result.getMessage().contains("\"type\":\"exception\"")); + assertTrue(result.getMessage().contains("\"reason\":\"Test exception\"")); + assertTrue(result.getMessage().contains("\"status\":400")); } public void testIllegalArgumentExceptionConversion() { @@ -50,11 +56,10 @@ public void testIllegalArgumentExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // IllegalArgumentException -> INVALID_ARGUMENT via direct gRPC mapping assertEquals(Status.INVALID_ARGUMENT.getCode(), result.getStatus().getCode()); - // Now includes full exception information for debugging (preserves original responseObserver.onError(e) behavior) - assertTrue(result.getMessage().contains("Invalid parameter")); - assertTrue(result.getMessage().contains("IllegalArgumentException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.lang.IllegalArgumentException: Invalid parameter")); } public void testInputCoercionExceptionConversion() { @@ -62,10 +67,11 @@ public void testInputCoercionExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // InputCoercionException -> INVALID_ARGUMENT via direct gRPC mapping assertEquals(Status.INVALID_ARGUMENT.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Cannot coerce string to number")); + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging assertTrue(result.getMessage().contains("InputCoercionException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + assertTrue(result.getMessage().contains("Cannot coerce string to number")); } public void testJsonParseExceptionConversion() { @@ -73,10 +79,11 @@ public void testJsonParseExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // JsonParseException -> INVALID_ARGUMENT via direct gRPC mapping assertEquals(Status.INVALID_ARGUMENT.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Unexpected character")); + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging assertTrue(result.getMessage().contains("JsonParseException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + assertTrue(result.getMessage().contains("Unexpected character")); } public void testOpenSearchRejectedExecutionExceptionConversion() { @@ -84,21 +91,11 @@ public void testOpenSearchRejectedExecutionExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // OpenSearchRejectedExecutionException -> RESOURCE_EXHAUSTED via direct gRPC mapping assertEquals(Status.RESOURCE_EXHAUSTED.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Thread pool full")); + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging assertTrue(result.getMessage().contains("OpenSearchRejectedExecutionException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator - } - - public void testNotXContentExceptionConversion() { - NotXContentException exception = new NotXContentException("Content is not XContent"); - - StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); - - assertEquals(Status.INVALID_ARGUMENT.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Content is not XContent")); - assertTrue(result.getMessage().contains("NotXContentException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + assertTrue(result.getMessage().contains("Thread pool full")); } public void testIllegalStateExceptionConversion() { @@ -106,10 +103,10 @@ public void testIllegalStateExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // IllegalStateException -> FAILED_PRECONDITION via direct gRPC mapping assertEquals(Status.FAILED_PRECONDITION.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Invalid state")); - assertTrue(result.getMessage().contains("IllegalStateException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.lang.IllegalStateException: Invalid state")); } public void testSecurityExceptionConversion() { @@ -117,10 +114,10 @@ public void testSecurityExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // SecurityException -> PERMISSION_DENIED via direct gRPC mapping assertEquals(Status.PERMISSION_DENIED.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Access denied")); - assertTrue(result.getMessage().contains("SecurityException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.lang.SecurityException: Access denied")); } public void testTimeoutExceptionConversion() { @@ -128,10 +125,10 @@ public void testTimeoutExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // TimeoutException -> DEADLINE_EXCEEDED via direct gRPC mapping assertEquals(Status.DEADLINE_EXCEEDED.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("Operation timed out")); - assertTrue(result.getMessage().contains("TimeoutException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.util.concurrent.TimeoutException: Operation timed out")); } public void testInterruptedExceptionConversion() { @@ -139,9 +136,10 @@ public void testInterruptedExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // InterruptedException -> CANCELLED via direct gRPC mapping assertEquals(Status.CANCELLED.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("InterruptedException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.lang.InterruptedException")); } public void testIOExceptionConversion() { @@ -149,10 +147,10 @@ public void testIOExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // IOException -> INTERNAL via direct gRPC mapping assertEquals(Status.INTERNAL.getCode(), result.getStatus().getCode()); - assertTrue(result.getMessage().contains("I/O error")); - assertTrue(result.getMessage().contains("IOException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.io.IOException: I/O error")); } public void testUnknownExceptionConversion() { @@ -160,11 +158,10 @@ public void testUnknownExceptionConversion() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // RuntimeException -> INTERNAL via fallback (unknown exception type) assertEquals(Status.INTERNAL.getCode(), result.getStatus().getCode()); - // Now includes full exception information for debugging - assertTrue(result.getMessage().contains("Unknown error")); - assertTrue(result.getMessage().contains("RuntimeException")); - assertTrue(result.getMessage().contains("at ")); // Stack trace indicator + // Uses ExceptionsHelper.stackTrace() - includes full stack trace for debugging + assertTrue(result.getMessage().contains("java.lang.RuntimeException: Unknown error")); } public void testOpenSearchExceptionWithNullMessage() { @@ -177,8 +174,11 @@ public RestStatus status() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + // NOT_FOUND -> NOT_FOUND via RestToGrpcStatusConverter assertEquals(Status.NOT_FOUND.getCode(), result.getStatus().getCode()); + // Uses ExceptionsHelper.summaryMessage() format + XContent details assertTrue(result.getMessage().contains("OpenSearchException[null]")); + assertTrue(result.getMessage().contains("details=")); } public void testCircuitBreakingExceptionInCleanMessage() { @@ -186,9 +186,11 @@ public void testCircuitBreakingExceptionInCleanMessage() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); - assertEquals(Status.RESOURCE_EXHAUSTED.getCode(), result.getStatus().getCode()); // CircuitBreakingException -> TOO_MANY_REQUESTS -> - // RESOURCE_EXHAUSTED + // CircuitBreakingException extends OpenSearchException with TOO_MANY_REQUESTS -> RESOURCE_EXHAUSTED + assertEquals(Status.RESOURCE_EXHAUSTED.getCode(), result.getStatus().getCode()); + // Uses ExceptionsHelper.summaryMessage() format + XContent details for OpenSearchException assertTrue(result.getMessage().contains("CircuitBreakingException[Memory circuit breaker]")); + assertTrue(result.getMessage().contains("details=")); } public void testSearchPhaseExecutionExceptionInCleanMessage() { @@ -200,8 +202,81 @@ public void testSearchPhaseExecutionExceptionInCleanMessage() { StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); - // SearchPhaseExecutionException with empty shardFailures -> SERVICE_UNAVAILABLE -> UNAVAILABLE + // SearchPhaseExecutionException extends OpenSearchException with SERVICE_UNAVAILABLE -> UNAVAILABLE assertEquals(Status.UNAVAILABLE.getCode(), result.getStatus().getCode()); + // Uses ExceptionsHelper.summaryMessage() format + XContent details for OpenSearchException assertTrue(result.getMessage().contains("SearchPhaseExecutionException[Search failed]")); + assertTrue(result.getMessage().contains("details=")); + // Should include phase information in XContent + assertTrue(result.getMessage().contains("\"phase\":\"query\"")); + } + + public void testXContentMetadataExtractionSuccess() { + // Test that XContent metadata extraction works for OpenSearchException + OpenSearchException exception = new OpenSearchException("Test with metadata") { + @Override + public RestStatus status() { + return RestStatus.BAD_REQUEST; + } + }; + exception.addMetadata("opensearch.test_key", "test_value"); + + StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + + // Should include metadata in JSON details + assertTrue(result.getMessage().contains("details=")); + assertTrue(result.getMessage().contains("\"test_key\":\"test_value\"")); + assertTrue(result.getMessage().contains("\"status\":400")); + } + + public void testXContentMetadataExtractionFailure() { + // Test graceful handling when XContent extraction fails + OpenSearchException exception = new OpenSearchException("Test exception") { + @Override + public RestStatus status() { + return RestStatus.BAD_REQUEST; + } + + @Override + public void metadataToXContent( + org.opensearch.core.xcontent.XContentBuilder builder, + org.opensearch.core.xcontent.ToXContent.Params params + ) throws java.io.IOException { + // Simulate XContent failure + throw new IOException("XContent failure"); + } + }; + + StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(exception); + + // Should fall back to base description when XContent extraction fails + assertEquals(Status.INVALID_ARGUMENT.getCode(), result.getStatus().getCode()); + assertTrue(result.getMessage().contains("OpenSearchException[Test exception]")); + // Should not contain details when extraction fails + assertFalse(result.getMessage().contains("details=")); + } + + public void testRootCauseAnalysis() { + // Test that root_cause analysis is included like HTTP responses + OpenSearchException rootCause = new OpenSearchException("Root cause") { + @Override + public RestStatus status() { + return RestStatus.BAD_REQUEST; + } + }; + + OpenSearchException wrappedException = new OpenSearchException("Wrapper exception", rootCause) { + @Override + public RestStatus status() { + return RestStatus.INTERNAL_SERVER_ERROR; + } + }; + + StatusRuntimeException result = GrpcErrorHandler.convertToGrpcError(wrappedException); + + // Should include root_cause array like HTTP responses + assertTrue(result.getMessage().contains("root_cause")); + assertTrue(result.getMessage().contains("Root cause")); + assertTrue(result.getMessage().contains("Wrapper exception")); } } From 7393363544ac39384bc3e7632d3bd4e92b4f0b89 Mon Sep 17 00:00:00 2001 From: Karen X Date: Thu, 9 Oct 2025 21:47:02 +0000 Subject: [PATCH 2/2] [GRPC] Single doc ingestion APIs Signed-off-by: Karen X --- CHANGELOG.md | 1 + .../DeleteDocumentActionListener.java | 58 ++ .../listeners/GetDocumentActionListener.java | 58 ++ .../IndexDocumentActionListener.java | 58 ++ .../UpdateDocumentActionListener.java | 58 ++ .../DeleteDocumentRequestProtoUtils.java | 97 +++ .../GetDocumentRequestProtoUtils.java | 100 +++ .../IndexDocumentRequestProtoUtils.java | 123 ++++ .../UpdateDocumentRequestProtoUtils.java | 167 +++++ .../document/bulk/BulkRequestProtoUtils.java | 23 +- .../common/DocWriteResponseProtoUtils.java | 45 ++ .../response/common/ShardInfoProtoUtils.java | 43 ++ .../DeleteDocumentResponseProtoUtils.java | 72 +++ .../GetDocumentResponseProtoUtils.java | 85 +++ .../IndexDocumentResponseProtoUtils.java | 76 +++ .../UpdateDocumentResponseProtoUtils.java | 77 +++ .../grpc/services/DocumentServiceImpl.java | 116 +++- .../DocumentServiceIntegrationTests.java | 611 ++++++++++++++++++ .../DeleteDocumentActionListenerTests.java | 322 +++++++++ .../GetDocumentActionListenerTests.java | 515 +++++++++++++++ .../IndexDocumentActionListenerTests.java | 68 ++ .../UpdateDocumentActionListenerTests.java | 328 ++++++++++ .../DeleteDocumentRequestProtoUtilsTests.java | 325 ++++++++++ .../GetDocumentRequestProtoUtilsTests.java | 459 +++++++++++++ .../IndexDocumentRequestProtoUtilsTests.java | 128 ++++ .../UpdateDocumentRequestProtoUtilsTests.java | 298 +++++++++ ...DeleteDocumentResponseProtoUtilsTests.java | 215 ++++++ .../GetDocumentResponseProtoUtilsTests.java | 382 +++++++++++ .../IndexDocumentResponseProtoUtilsTests.java | 71 ++ ...UpdateDocumentResponseProtoUtilsTests.java | 198 ++++++ .../services/DocumentServiceImplTests.java | 565 ++++++++++++++++ 31 files changed, 5734 insertions(+), 8 deletions(-) create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListener.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListener.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListener.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListener.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/DocWriteResponseProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/ShardInfoProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtils.java create mode 100644 modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtils.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/integration/DocumentServiceIntegrationTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListenerTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListenerTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListenerTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListenerTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtilsTests.java create mode 100644 modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/services/DocumentServiceImplTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f050227c82d64..e3b9bd60edd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Add streaming cardinality aggregator ([#19484](https://github.com/opensearch-project/OpenSearch/pull/19484)) - Disable request cache for streaming aggregation queries ([#19520](https://github.com/opensearch-project/OpenSearch/pull/19520)) - Return full error for GRPC error response ([#19568](https://github.com/opensearch-project/OpenSearch/pull/19568)) +- Implement IndexDocument, UpdateDocument, GetDocument, DeleteDocument GRPC APIs ([#19590](https://github.com/opensearch-project/OpenSearch/pull/19590)) ### Changed - Refactor `if-else` chains to use `Java 17 pattern matching switch expressions`(([#18965](https://github.com/opensearch-project/OpenSearch/pull/18965)) diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListener.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListener.java new file mode 100644 index 0000000000000..5691f349184a8 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListener.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.core.action.ActionListener; +import org.opensearch.protobufs.DeleteDocumentResponse; +import org.opensearch.transport.grpc.proto.response.document.DeleteDocumentResponseProtoUtils; +import org.opensearch.transport.grpc.util.GrpcErrorHandler; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +/** + * Listener for delete document request execution completion, handling successful and failure scenarios. + */ +public class DeleteDocumentActionListener implements ActionListener { + private static final Logger logger = LogManager.getLogger(DeleteDocumentActionListener.class); + + private final StreamObserver responseObserver; + + /** + * Constructs a new DeleteDocumentActionListener. + * + * @param responseObserver the gRPC stream observer to send the delete response to + */ + public DeleteDocumentActionListener(StreamObserver responseObserver) { + this.responseObserver = responseObserver; + } + + @Override + public void onResponse(DeleteResponse response) { + try { + DeleteDocumentResponse protoResponse = DeleteDocumentResponseProtoUtils.toProto(response); + responseObserver.onNext(protoResponse); + responseObserver.onCompleted(); + } catch (Exception e) { + logger.error("Failed to convert delete response to protobuf: " + e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + @Override + public void onFailure(Exception e) { + logger.warn("DeleteDocumentActionListener failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListener.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListener.java new file mode 100644 index 0000000000000..de0a5491b9bea --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListener.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.get.GetResponse; +import org.opensearch.core.action.ActionListener; +import org.opensearch.protobufs.GetDocumentResponse; +import org.opensearch.transport.grpc.proto.response.document.GetDocumentResponseProtoUtils; +import org.opensearch.transport.grpc.util.GrpcErrorHandler; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +/** + * Listener for get document request execution completion, handling successful and failure scenarios. + */ +public class GetDocumentActionListener implements ActionListener { + private static final Logger logger = LogManager.getLogger(GetDocumentActionListener.class); + + private final StreamObserver responseObserver; + + /** + * Constructs a new GetDocumentActionListener. + * + * @param responseObserver the gRPC stream observer to send the get response to + */ + public GetDocumentActionListener(StreamObserver responseObserver) { + this.responseObserver = responseObserver; + } + + @Override + public void onResponse(GetResponse response) { + try { + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(response); + responseObserver.onNext(protoResponse); + responseObserver.onCompleted(); + } catch (Exception e) { + logger.error("Failed to convert get response to protobuf: " + e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + @Override + public void onFailure(Exception e) { + logger.warn("GetDocumentActionListener failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListener.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListener.java new file mode 100644 index 0000000000000..5227ca3107c43 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListener.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.core.action.ActionListener; +import org.opensearch.protobufs.IndexDocumentResponse; +import org.opensearch.transport.grpc.proto.response.document.IndexDocumentResponseProtoUtils; +import org.opensearch.transport.grpc.util.GrpcErrorHandler; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +/** + * Listener for index document request execution completion, handling successful and failure scenarios. + */ +public class IndexDocumentActionListener implements ActionListener { + private static final Logger logger = LogManager.getLogger(IndexDocumentActionListener.class); + + private final StreamObserver responseObserver; + + /** + * Constructs a new IndexDocumentActionListener. + * + * @param responseObserver the gRPC stream observer to send the index response to + */ + public IndexDocumentActionListener(StreamObserver responseObserver) { + this.responseObserver = responseObserver; + } + + @Override + public void onResponse(IndexResponse response) { + try { + IndexDocumentResponse protoResponse = IndexDocumentResponseProtoUtils.toProto(response); + responseObserver.onNext(protoResponse); + responseObserver.onCompleted(); + } catch (Exception e) { + logger.error("Failed to convert index response to protobuf: " + e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + @Override + public void onFailure(Exception e) { + logger.warn("IndexDocumentActionListener failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListener.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListener.java new file mode 100644 index 0000000000000..51f9beefd3152 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListener.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.update.UpdateResponse; +import org.opensearch.core.action.ActionListener; +import org.opensearch.protobufs.UpdateDocumentResponse; +import org.opensearch.transport.grpc.proto.response.document.UpdateDocumentResponseProtoUtils; +import org.opensearch.transport.grpc.util.GrpcErrorHandler; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +/** + * Listener for update document request execution completion, handling successful and failure scenarios. + */ +public class UpdateDocumentActionListener implements ActionListener { + private static final Logger logger = LogManager.getLogger(UpdateDocumentActionListener.class); + + private final StreamObserver responseObserver; + + /** + * Constructs a new UpdateDocumentActionListener. + * + * @param responseObserver the gRPC stream observer to send the update response to + */ + public UpdateDocumentActionListener(StreamObserver responseObserver) { + this.responseObserver = responseObserver; + } + + @Override + public void onResponse(UpdateResponse response) { + try { + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(response); + responseObserver.onNext(protoResponse); + responseObserver.onCompleted(); + } catch (Exception e) { + logger.error("Failed to convert update response to protobuf: " + e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + @Override + public void onFailure(Exception e) { + logger.warn("UpdateDocumentActionListener failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtils.java new file mode 100644 index 0000000000000..c01dcd0a4e72d --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtils.java @@ -0,0 +1,97 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.protobufs.DeleteDocumentRequest; + +/** + * Utility class for converting protobuf DeleteDocumentRequest to OpenSearch DeleteRequest. + *

+ * This class provides functionality similar to the REST layer's delete document request processing. + * The parameter mapping and processing logic should be kept consistent with the corresponding + * REST implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.rest.action.document.RestDeleteAction#prepareRequest(RestRequest, NodeClient) REST equivalent for parameter processing + * @see org.opensearch.action.delete.DeleteRequest OpenSearch internal delete request representation + * @see org.opensearch.protobufs.DeleteDocumentRequest Protobuf definition for gRPC delete requests + */ +public class DeleteDocumentRequestProtoUtils { + + private DeleteDocumentRequestProtoUtils() { + // Utility class, no instances + } + + /** + * Converts a protobuf DeleteDocumentRequest to an OpenSearch DeleteRequest. + *

+ * This method processes delete document request parameters similar to how + * {@link org.opensearch.rest.action.document.RestDeleteAction#prepareRequest(RestRequest, NodeClient)} + * processes REST requests. Parameter mapping includes index name, document ID, routing, + * timeout, refresh policy, versioning, and wait for active shards. + * + * @param protoRequest The protobuf DeleteDocumentRequest to convert + * @return The corresponding OpenSearch DeleteRequest + * @throws IllegalArgumentException if required fields are missing or invalid + * @see org.opensearch.rest.action.document.RestDeleteAction#prepareRequest(RestRequest, NodeClient) REST equivalent + */ + public static DeleteRequest fromProto(DeleteDocumentRequest protoRequest) { + if (protoRequest.getIndex().isEmpty()) { + throw new IllegalArgumentException("Index name is required"); + } + + if (protoRequest.getId().isEmpty()) { + throw new IllegalArgumentException("Document ID is required"); + } + + DeleteRequest deleteRequest = new DeleteRequest(protoRequest.getIndex(), protoRequest.getId()); + + // Set routing if provided + if (protoRequest.hasRouting() && !protoRequest.getRouting().isEmpty()) { + deleteRequest.routing(protoRequest.getRouting()); + } + + // Set refresh policy if provided + if (protoRequest.hasRefresh()) { + deleteRequest.setRefreshPolicy(convertRefresh(protoRequest.getRefresh())); + } + + // Set version constraints if provided + if (protoRequest.hasIfSeqNo()) { + deleteRequest.setIfSeqNo(protoRequest.getIfSeqNo()); + } + + if (protoRequest.hasIfPrimaryTerm()) { + deleteRequest.setIfPrimaryTerm(protoRequest.getIfPrimaryTerm()); + } + + // Set timeout if provided + if (protoRequest.hasTimeout() && !protoRequest.getTimeout().isEmpty()) { + deleteRequest.timeout(protoRequest.getTimeout()); + } + + return deleteRequest; + } + + /** + * Convert protobuf Refresh to WriteRequest.RefreshPolicy. + */ + private static WriteRequest.RefreshPolicy convertRefresh(org.opensearch.protobufs.Refresh refresh) { + switch (refresh) { + case REFRESH_TRUE: + return WriteRequest.RefreshPolicy.IMMEDIATE; + case REFRESH_WAIT_FOR: + return WriteRequest.RefreshPolicy.WAIT_UNTIL; + case REFRESH_FALSE: + default: + return WriteRequest.RefreshPolicy.NONE; + } + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtils.java new file mode 100644 index 0000000000000..8968daac35283 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtils.java @@ -0,0 +1,100 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import org.opensearch.action.get.GetRequest; +import org.opensearch.protobufs.GetDocumentRequest; +import org.opensearch.search.fetch.subphase.FetchSourceContext; + +/** + * Utility class for converting protobuf GetDocumentRequest to OpenSearch GetRequest. + *

+ * This class provides functionality similar to the REST layer's get document request processing. + * The parameter mapping and processing logic should be kept consistent with the corresponding + * REST implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.rest.action.document.RestGetAction#prepareRequest(RestRequest, NodeClient) REST equivalent for parameter processing + * @see org.opensearch.action.get.GetRequest OpenSearch internal get request representation + * @see org.opensearch.protobufs.GetDocumentRequest Protobuf definition for gRPC get requests + */ +public class GetDocumentRequestProtoUtils { + + private GetDocumentRequestProtoUtils() { + // Utility class, no instances + } + + /** + * Converts a protobuf GetDocumentRequest to an OpenSearch GetRequest. + *

+ * This method processes get document request parameters similar to how + * {@link org.opensearch.rest.action.document.RestGetAction#prepareRequest(RestRequest, NodeClient)} + * processes REST requests. Parameter mapping includes index name, document ID, routing, + * preference, realtime, refresh, stored fields, versioning, and source filtering. + * + * @param protoRequest The protobuf GetDocumentRequest to convert + * @return The corresponding OpenSearch GetRequest + * @throws IllegalArgumentException if required fields are missing or invalid + * @see org.opensearch.rest.action.document.RestGetAction#prepareRequest(RestRequest, NodeClient) REST equivalent + */ + public static GetRequest fromProto(GetDocumentRequest protoRequest) { + if (protoRequest.getIndex().isEmpty()) { + throw new IllegalArgumentException("Index name is required"); + } + + if (protoRequest.getId().isEmpty()) { + throw new IllegalArgumentException("Document ID is required"); + } + + GetRequest getRequest = new GetRequest(protoRequest.getIndex(), protoRequest.getId()); + + // Set routing if provided + if (protoRequest.hasRouting() && !protoRequest.getRouting().isEmpty()) { + getRequest.routing(protoRequest.getRouting()); + } + + // Set preference if provided + if (protoRequest.hasPreference() && !protoRequest.getPreference().isEmpty()) { + getRequest.preference(protoRequest.getPreference()); + } + + // Set realtime if provided + if (protoRequest.hasRealtime()) { + getRequest.realtime(protoRequest.getRealtime()); + } + + // Set refresh if provided + if (protoRequest.hasRefresh()) { + getRequest.refresh(protoRequest.getRefresh()); + } + + // Set stored fields if provided + if (!protoRequest.getStoredFieldsList().isEmpty()) { + String[] storedFields = protoRequest.getStoredFieldsList().toArray(new String[0]); + getRequest.storedFields(storedFields); + } + + // Set version if provided + if (protoRequest.hasVersion()) { + getRequest.version(protoRequest.getVersion()); + } + + // Set source configuration if provided + if (!protoRequest.getXSourceExcludesList().isEmpty() || !protoRequest.getXSourceIncludesList().isEmpty()) { + String[] includes = protoRequest.getXSourceIncludesList().isEmpty() + ? null + : protoRequest.getXSourceIncludesList().toArray(new String[0]); + String[] excludes = protoRequest.getXSourceExcludesList().isEmpty() + ? null + : protoRequest.getXSourceExcludesList().toArray(new String[0]); + getRequest.fetchSourceContext(new FetchSourceContext(true, includes, excludes)); + } + + return getRequest; + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtils.java new file mode 100644 index 0000000000000..d60f209292580 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtils.java @@ -0,0 +1,123 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.protobufs.IndexDocumentRequest; +import org.opensearch.transport.grpc.proto.request.common.OpTypeProtoUtils; + +/** + * Utility class for converting protobuf IndexDocumentRequest to OpenSearch IndexRequest. + *

+ * This class provides functionality similar to the REST layer's index document request processing. + * The parameter mapping and processing logic should be kept consistent with the corresponding + * REST implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.rest.action.document.RestIndexAction#prepareRequest(RestRequest, NodeClient) REST equivalent for parameter processing + * @see org.opensearch.action.index.IndexRequest OpenSearch internal index request representation + * @see org.opensearch.protobufs.IndexDocumentRequest Protobuf definition for gRPC index requests + */ +public class IndexDocumentRequestProtoUtils { + + private IndexDocumentRequestProtoUtils() { + // Utility class, no instances + } + + /** + * Converts a protobuf IndexDocumentRequest to an OpenSearch IndexRequest. + *

+ * This method processes index document request parameters similar to how + * {@link org.opensearch.rest.action.document.RestIndexAction#prepareRequest(RestRequest, NodeClient)} + * processes REST requests. Parameter mapping includes index name, document ID, routing, + * pipeline, source content, timeout, refresh policy, versioning, and operation type. + * + * @param protoRequest The protobuf IndexDocumentRequest to convert + * @return The corresponding OpenSearch IndexRequest + * @throws IllegalArgumentException if required fields are missing or invalid + * @see org.opensearch.rest.action.document.RestIndexAction#prepareRequest(RestRequest, NodeClient) REST equivalent + */ + public static IndexRequest fromProto(IndexDocumentRequest protoRequest) { + if (!protoRequest.hasIndex() || protoRequest.getIndex().isEmpty()) { + throw new IllegalArgumentException("Index name is required"); + } + + IndexRequest indexRequest = new IndexRequest(protoRequest.getIndex()); + + // Set document ID if provided + if (protoRequest.hasId() && !protoRequest.getId().isEmpty()) { + indexRequest.id(protoRequest.getId()); + } + + // Set document content based on the oneof field + if (protoRequest.hasBytesRequestBody()) { + BytesReference documentBytes = new BytesArray(protoRequest.getBytesRequestBody().toByteArray()); + indexRequest.source(documentBytes, XContentType.JSON); + } else if (protoRequest.hasRequestBody()) { + // For now, we don't support ObjectMap - would need ObjectMapProtoUtils + throw new UnsupportedOperationException("ObjectMap request body not yet supported, use bytes_request_body"); + } else { + throw new IllegalArgumentException("Document content is required (use bytes_request_body field)"); + } + + // Set operation type if provided + if (protoRequest.hasOpType()) { + indexRequest.opType(OpTypeProtoUtils.fromProto(protoRequest.getOpType())); + } + + // Set routing if provided + if (protoRequest.hasRouting() && !protoRequest.getRouting().isEmpty()) { + indexRequest.routing(protoRequest.getRouting()); + } + + // Set refresh policy if provided + if (protoRequest.hasRefresh()) { + indexRequest.setRefreshPolicy(convertRefresh(protoRequest.getRefresh())); + } + + // Set version constraints if provided + if (protoRequest.hasIfSeqNo()) { + indexRequest.setIfSeqNo(protoRequest.getIfSeqNo()); + } + + if (protoRequest.hasIfPrimaryTerm()) { + indexRequest.setIfPrimaryTerm(protoRequest.getIfPrimaryTerm()); + } + + // Set pipeline if provided + if (protoRequest.hasPipeline() && !protoRequest.getPipeline().isEmpty()) { + indexRequest.setPipeline(protoRequest.getPipeline()); + } + + // Set timeout if provided + if (protoRequest.hasTimeout() && !protoRequest.getTimeout().isEmpty()) { + indexRequest.timeout(protoRequest.getTimeout()); + } + + return indexRequest; + } + + /** + * Convert protobuf Refresh to WriteRequest.RefreshPolicy. + */ + private static WriteRequest.RefreshPolicy convertRefresh(org.opensearch.protobufs.Refresh refresh) { + switch (refresh) { + case REFRESH_TRUE: + return WriteRequest.RefreshPolicy.IMMEDIATE; + case REFRESH_WAIT_FOR: + return WriteRequest.RefreshPolicy.WAIT_UNTIL; + case REFRESH_FALSE: + default: + return WriteRequest.RefreshPolicy.NONE; + } + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtils.java new file mode 100644 index 0000000000000..9729e5bca1d2d --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtils.java @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.protobufs.UpdateDocumentRequest; +import org.opensearch.protobufs.UpdateDocumentRequestBody; +import org.opensearch.search.fetch.subphase.FetchSourceContext; + +/** + * Utility class for converting protobuf UpdateDocumentRequest to OpenSearch UpdateRequest. + *

+ * This class provides functionality similar to the REST layer's update document request processing. + * The parameter mapping and processing logic should be kept consistent with the corresponding + * REST implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.rest.action.document.RestUpdateAction#prepareRequest(RestRequest, NodeClient) REST equivalent for parameter processing + * @see org.opensearch.action.update.UpdateRequest OpenSearch internal update request representation + * @see org.opensearch.protobufs.UpdateDocumentRequest Protobuf definition for gRPC update requests + */ +public class UpdateDocumentRequestProtoUtils { + + private UpdateDocumentRequestProtoUtils() { + // Utility class, no instances + } + + /** + * Converts a protobuf UpdateDocumentRequest to an OpenSearch UpdateRequest. + *

+ * This method processes update document request parameters similar to how + * {@link org.opensearch.rest.action.document.RestUpdateAction#prepareRequest(RestRequest, NodeClient)} + * processes REST requests. Parameter mapping includes index name, document ID, routing, + * refresh policy, versioning, retry on conflict, timeout, source filtering, and request body + * (including doc updates, upserts, and scripted updates). + * + * @param protoRequest The protobuf UpdateDocumentRequest to convert + * @return The corresponding OpenSearch UpdateRequest + * @throws IllegalArgumentException if required fields are missing or invalid + * @see org.opensearch.rest.action.document.RestUpdateAction#prepareRequest(RestRequest, NodeClient) REST equivalent + */ + public static UpdateRequest fromProto(UpdateDocumentRequest protoRequest) { + if (!protoRequest.hasIndex() || protoRequest.getIndex().isEmpty()) { + throw new IllegalArgumentException("Index name is required"); + } + + if (!protoRequest.hasId() || protoRequest.getId().isEmpty()) { + throw new IllegalArgumentException("Document ID is required"); + } + + UpdateRequest updateRequest = new UpdateRequest(protoRequest.getIndex(), protoRequest.getId()); + + // Set routing if provided + if (protoRequest.hasRouting() && !protoRequest.getRouting().isEmpty()) { + updateRequest.routing(protoRequest.getRouting()); + } + + // Set refresh policy if provided + if (protoRequest.hasRefresh()) { + updateRequest.setRefreshPolicy(convertRefresh(protoRequest.getRefresh())); + } + + // Set version constraints if provided + if (protoRequest.hasIfSeqNo()) { + updateRequest.setIfSeqNo(protoRequest.getIfSeqNo()); + } + + if (protoRequest.hasIfPrimaryTerm()) { + updateRequest.setIfPrimaryTerm(protoRequest.getIfPrimaryTerm()); + } + + // Set retry on conflict if provided + if (protoRequest.hasRetryOnConflict()) { + updateRequest.retryOnConflict(protoRequest.getRetryOnConflict()); + } + + // Set timeout if provided + if (protoRequest.hasTimeout() && !protoRequest.getTimeout().isEmpty()) { + updateRequest.timeout(protoRequest.getTimeout()); + } + + // Set source configuration if provided + if (!protoRequest.getXSourceExcludesList().isEmpty()) { + String[] excludes = protoRequest.getXSourceExcludesList().toArray(new String[0]); + updateRequest.fetchSource(new FetchSourceContext(true, null, excludes)); + } + + if (!protoRequest.getXSourceIncludesList().isEmpty()) { + String[] includes = protoRequest.getXSourceIncludesList().toArray(new String[0]); + updateRequest.fetchSource(new FetchSourceContext(true, includes, null)); + } + + // Process request body if provided + if (protoRequest.hasRequestBody()) { + processRequestBody(updateRequest, protoRequest.getRequestBody()); + } + + return updateRequest; + } + + /** + * Processes the update request body and applies it to the UpdateRequest. + * + * @param updateRequest The OpenSearch UpdateRequest to modify + * @param requestBody The protobuf UpdateDocumentRequestBody + */ + private static void processRequestBody(UpdateRequest updateRequest, UpdateDocumentRequestBody requestBody) { + // Set detect noop if provided + if (requestBody.hasDetectNoop()) { + updateRequest.detectNoop(requestBody.getDetectNoop()); + } + + // Set partial document update if provided + if (requestBody.hasBytesDoc()) { + BytesReference docBytes = new BytesArray(requestBody.getBytesDoc().toByteArray()); + updateRequest.doc(docBytes, XContentType.JSON); + } else if (requestBody.hasDoc()) { + // For now, we don't support ObjectMap - would need ObjectMapProtoUtils + throw new UnsupportedOperationException("ObjectMap doc updates not yet supported, use bytes_doc"); + } + + // Set doc_as_upsert if provided + if (requestBody.hasDocAsUpsert()) { + updateRequest.docAsUpsert(requestBody.getDocAsUpsert()); + } + + // Set scripted_upsert if provided + if (requestBody.hasScriptedUpsert()) { + updateRequest.scriptedUpsert(requestBody.getScriptedUpsert()); + } + + // Set upsert document if provided + if (requestBody.hasUpsert()) { + BytesReference upsertBytes = new BytesArray(requestBody.getUpsert().toByteArray()); + updateRequest.upsert(upsertBytes, XContentType.JSON); + } + + // Note: Script support would require ScriptProtoUtils implementation + if (requestBody.hasScript()) { + throw new UnsupportedOperationException("Script updates not yet supported"); + } + } + + /** + * Convert protobuf Refresh to WriteRequest.RefreshPolicy. + */ + private static WriteRequest.RefreshPolicy convertRefresh(org.opensearch.protobufs.Refresh refresh) { + switch (refresh) { + case REFRESH_TRUE: + return WriteRequest.RefreshPolicy.IMMEDIATE; + case REFRESH_WAIT_FOR: + return WriteRequest.RefreshPolicy.WAIT_UNTIL; + case REFRESH_FALSE: + default: + return WriteRequest.RefreshPolicy.NONE; + } + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestProtoUtils.java index 7f336e56e87a9..3a4e6b95a7b9d 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestProtoUtils.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestProtoUtils.java @@ -19,7 +19,15 @@ import org.opensearch.transport.grpc.proto.request.common.RefreshProtoUtils; /** - * Handler for bulk requests in gRPC. + * Utility class for converting protobuf BulkRequest to OpenSearch BulkRequest. + *

+ * This class provides functionality similar to the REST layer's bulk request processing. + * The parameter mapping and processing logic should be kept consistent with the corresponding + * REST implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.rest.action.document.RestBulkAction#prepareRequest(RestRequest, NodeClient) REST equivalent for parameter processing + * @see org.opensearch.action.bulk.BulkRequest OpenSearch internal bulk request representation + * @see org.opensearch.protobufs.BulkRequest Protobuf definition for gRPC bulk requests */ public class BulkRequestProtoUtils { // protected final Settings settings; @@ -32,12 +40,15 @@ private BulkRequestProtoUtils() { } /** - * Prepare the request for execution. - * Similar to {@link RestBulkAction#prepareRequest(RestRequest, NodeClient)} - * Please ensure to keep both implementations consistent. + * Converts a protobuf BulkRequest to an OpenSearch BulkRequest. + *

+ * This method processes bulk request parameters and prepares the request for execution, + * similar to {@link RestBulkAction#prepareRequest(RestRequest, NodeClient)}. + * Please ensure to keep both implementations consistent for feature parity. * - * @param request the request to execute - * @return a future of the bulk action that was executed + * @param request the protobuf bulk request to convert + * @return the corresponding OpenSearch BulkRequest ready for execution + * @see org.opensearch.rest.action.document.RestBulkAction#prepareRequest(RestRequest, NodeClient) REST equivalent */ public static org.opensearch.action.bulk.BulkRequest prepareRequest(BulkRequest request) { org.opensearch.action.bulk.BulkRequest bulkRequest = Requests.bulkRequest(); diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/DocWriteResponseProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/DocWriteResponseProtoUtils.java new file mode 100644 index 0000000000000..018b883123a81 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/DocWriteResponseProtoUtils.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.common; + +import org.opensearch.action.DocWriteResponse; +import org.opensearch.protobufs.Result; + +/** + * Utility class for converting DocWriteResponse fields to protobuf. + */ +public class DocWriteResponseProtoUtils { + + private DocWriteResponseProtoUtils() { + // Utility class, no instances + } + + /** + * Converts a DocWriteResponse.Result to protobuf Result. + * + * @param result The DocWriteResponse.Result + * @return The corresponding protobuf Result + */ + public static Result resultToProto(DocWriteResponse.Result result) { + switch (result) { + case CREATED: + return Result.RESULT_CREATED; + case UPDATED: + return Result.RESULT_UPDATED; + case DELETED: + return Result.RESULT_DELETED; + case NOT_FOUND: + return Result.RESULT_NOT_FOUND; + case NOOP: + return Result.RESULT_NOOP; + default: + return Result.RESULT_UNSPECIFIED; + } + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/ShardInfoProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/ShardInfoProtoUtils.java new file mode 100644 index 0000000000000..ca27d56cac3ee --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/ShardInfoProtoUtils.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.common; + +import org.opensearch.action.support.replication.ReplicationResponse; +import org.opensearch.protobufs.ShardInfo; + +/** + * Utility class for converting ReplicationResponse.ShardInfo to protobuf. + */ +public class ShardInfoProtoUtils { + + private ShardInfoProtoUtils() { + // Utility class, no instances + } + + /** + * Converts a ReplicationResponse.ShardInfo to protobuf ShardInfo. + * + * @param shardInfo The ReplicationResponse.ShardInfo + * @return The corresponding protobuf ShardInfo + */ + public static ShardInfo toProto(ReplicationResponse.ShardInfo shardInfo) { + ShardInfo.Builder builder = ShardInfo.newBuilder() + .setTotal(shardInfo.getTotal()) + .setSuccessful(shardInfo.getSuccessful()) + .setFailed(shardInfo.getFailed()); + + // Add failure details if any + if (shardInfo.getFailures() != null && shardInfo.getFailures().length > 0) { + // For now, we'll skip detailed failure conversion + // This would require additional proto utils for failure types + } + + return builder.build(); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtils.java new file mode 100644 index 0000000000000..91fd55e71829d --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtils.java @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.protobufs.DeleteDocumentResponse; +import org.opensearch.protobufs.DeleteDocumentResponseBody; +import org.opensearch.transport.grpc.proto.response.common.DocWriteResponseProtoUtils; + +/** + * Utility class for converting OpenSearch DeleteResponse to protobuf DeleteDocumentResponse. + *

+ * This class provides functionality similar to the REST layer's delete response serialization. + * The response mapping and field serialization should be kept consistent with the corresponding + * REST XContent implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.action.delete.DeleteResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent for response serialization + * @see org.opensearch.action.delete.DeleteResponse#fromXContent(org.opensearch.core.xcontent.XContentParser) REST equivalent for response parsing + * @see org.opensearch.action.DocWriteResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) Base class XContent serialization + * @see org.opensearch.action.delete.DeleteResponse OpenSearch internal delete response representation + * @see org.opensearch.protobufs.DeleteDocumentResponse Protobuf definition for gRPC delete responses + */ +public class DeleteDocumentResponseProtoUtils { + + private DeleteDocumentResponseProtoUtils() { + // Utility class, no instances + } + + /** + * Converts an OpenSearch DeleteResponse to a protobuf DeleteDocumentResponse. + *

+ * This method serializes delete response fields similar to how + * {@link org.opensearch.action.DocWriteResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params)} + * serializes responses in REST. Field mapping includes index name, document ID, version, + * sequence number, primary term, result status, forced refresh, and shard information. + * + * @param deleteResponse The OpenSearch DeleteResponse to convert + * @return The corresponding protobuf DeleteDocumentResponse + * @see org.opensearch.action.DocWriteResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent + */ + public static DeleteDocumentResponse toProto(DeleteResponse deleteResponse) { + DeleteDocumentResponseBody.Builder responseBodyBuilder = DeleteDocumentResponseBody.newBuilder() + .setXIndex(deleteResponse.getIndex()) + .setXId(deleteResponse.getId()) + .setXPrimaryTerm(deleteResponse.getPrimaryTerm()) + .setXSeqNo(deleteResponse.getSeqNo()) + .setXVersion(deleteResponse.getVersion()) + .setResult(DocWriteResponseProtoUtils.resultToProto(deleteResponse.getResult())); + + // Note: ShardInfo not available in DeleteDocumentResponseBody protobuf definition + + return DeleteDocumentResponse.newBuilder().setDeleteDocumentResponseBody(responseBodyBuilder.build()).build(); + } + + /** + * Creates an error response from an exception. + * + * @param exception The exception that occurred + * @param statusCode The HTTP status code + * @return The protobuf DeleteDocumentResponse with error details + */ + public static DeleteDocumentResponse toErrorProto(Exception exception, RestStatus statusCode) { + throw new UnsupportedOperationException("Use GrpcErrorHandler.convertToGrpcError() instead"); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtils.java new file mode 100644 index 0000000000000..728e129d97b4b --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtils.java @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.get.GetResponse; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.protobufs.GetDocumentResponse; +import org.opensearch.protobufs.GetDocumentResponseBody; + +/** + * Utility class for converting OpenSearch GetResponse to protobuf GetDocumentResponse. + *

+ * This class provides functionality similar to the REST layer's get response serialization. + * The response mapping and field serialization should be kept consistent with the corresponding + * REST XContent implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.action.get.GetResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent for response serialization + * @see org.opensearch.action.get.GetResponse#fromXContent(org.opensearch.core.xcontent.XContentParser) REST equivalent for response parsing + * @see org.opensearch.index.get.GetResult#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) GetResult XContent serialization + * @see org.opensearch.action.get.GetResponse OpenSearch internal get response representation + * @see org.opensearch.protobufs.GetDocumentResponse Protobuf definition for gRPC get responses + */ +public class GetDocumentResponseProtoUtils { + + private GetDocumentResponseProtoUtils() { + // Utility class, no instances + } + + /** + * Converts an OpenSearch GetResponse to a protobuf GetDocumentResponse. + *

+ * This method serializes get response fields similar to how + * {@link org.opensearch.action.get.GetResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params)} + * serializes responses in REST. Field mapping includes index name, document ID, version, + * sequence number, primary term, found status, and document source content. + * + * @param getResponse The OpenSearch GetResponse to convert + * @return The corresponding protobuf GetDocumentResponse + * @see org.opensearch.action.get.GetResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent + * @see org.opensearch.index.get.GetResult#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) GetResult serialization + */ + public static GetDocumentResponse toProto(GetResponse getResponse) { + GetDocumentResponseBody.Builder responseBodyBuilder = GetDocumentResponseBody.newBuilder() + .setXIndex(getResponse.getIndex()) + .setXId(getResponse.getId()) + .setFound(getResponse.isExists()) + .setXVersion(getResponse.getVersion()) + .setXSeqNo(getResponse.getSeqNo()) + .setXPrimaryTerm(getResponse.getPrimaryTerm()); + + // Set source if available and document exists + if (getResponse.isExists() && getResponse.getSource() != null && !getResponse.getSource().isEmpty()) { + // Convert source map to JSON bytes using the x_source field + try { + String sourceJson = org.opensearch.common.xcontent.XContentHelper.convertToJson( + getResponse.getSourceAsBytesRef(), + false, + org.opensearch.common.xcontent.XContentType.JSON + ); + responseBodyBuilder.setXSource(com.google.protobuf.ByteString.copyFromUtf8(sourceJson)); + } catch (Exception e) { + // If source conversion fails, continue without it + } + } + + return GetDocumentResponse.newBuilder().setGetDocumentResponseBody(responseBodyBuilder.build()).build(); + } + + /** + * Creates an error response from an exception. + * + * @param exception The exception that occurred + * @param statusCode The HTTP status code + * @return The protobuf GetDocumentResponse with error details + */ + public static GetDocumentResponse toErrorProto(Exception exception, RestStatus statusCode) { + throw new UnsupportedOperationException("Use GrpcErrorHandler.convertToGrpcError() instead"); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtils.java new file mode 100644 index 0000000000000..0c991a8792d21 --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtils.java @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.index.IndexResponse; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.protobufs.IndexDocumentResponse; +import org.opensearch.protobufs.IndexDocumentResponseBody; +import org.opensearch.transport.grpc.proto.response.common.DocWriteResponseProtoUtils; + +/** + * Utility class for converting OpenSearch IndexResponse to protobuf IndexDocumentResponse. + *

+ * This class provides functionality similar to the REST layer's index response serialization. + * The response mapping and field serialization should be kept consistent with the corresponding + * REST XContent implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.action.index.IndexResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent for response serialization + * @see org.opensearch.action.index.IndexResponse#fromXContent(org.opensearch.core.xcontent.XContentParser) REST equivalent for response parsing + * @see org.opensearch.action.DocWriteResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) Base class XContent serialization + * @see org.opensearch.action.index.IndexResponse OpenSearch internal index response representation + * @see org.opensearch.protobufs.IndexDocumentResponse Protobuf definition for gRPC index responses + */ +public class IndexDocumentResponseProtoUtils { + + private IndexDocumentResponseProtoUtils() { + // Utility class, no instances + } + + /** + * Converts an OpenSearch IndexResponse to a protobuf IndexDocumentResponse. + *

+ * This method serializes index response fields similar to how + * {@link org.opensearch.action.index.IndexResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params)} + * serializes responses in REST. Field mapping includes index name, document ID, version, + * sequence number, primary term, result status, forced refresh, and shard information. + * + * @param indexResponse The OpenSearch IndexResponse to convert + * @return The corresponding protobuf IndexDocumentResponse + * @see org.opensearch.action.index.IndexResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent + * @see org.opensearch.action.DocWriteResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) Base class serialization + */ + public static IndexDocumentResponse toProto(IndexResponse indexResponse) { + IndexDocumentResponseBody.Builder responseBodyBuilder = IndexDocumentResponseBody.newBuilder() + .setXIndex(indexResponse.getIndex()) + .setXId(indexResponse.getId()) + .setXPrimaryTerm(indexResponse.getPrimaryTerm()) + .setXSeqNo(indexResponse.getSeqNo()) + .setXVersion(indexResponse.getVersion()) + .setResult(DocWriteResponseProtoUtils.resultToProto(indexResponse.getResult())); + + // Note: ShardInfo not available in IndexDocumentResponseBody protobuf definition + + return IndexDocumentResponse.newBuilder().setIndexDocumentResponseBody(responseBodyBuilder.build()).build(); + } + + /** + * Creates an error response from an exception. + * For now, we'll use gRPC status exceptions instead of structured error responses. + * + * @param exception The exception that occurred + * @param statusCode The HTTP status code + * @return The protobuf IndexDocumentResponse with error details + */ + public static IndexDocumentResponse toErrorProto(Exception exception, RestStatus statusCode) { + // For now, return a simple error response without the complex Error structure + // This can be enhanced later with proper Error protobuf mapping + throw new UnsupportedOperationException("Use GrpcErrorHandler.convertToGrpcError() instead"); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtils.java new file mode 100644 index 0000000000000..2d4eb314bc9ba --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtils.java @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.update.UpdateResponse; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.protobufs.UpdateDocumentResponse; +import org.opensearch.protobufs.UpdateDocumentResponseBody; +import org.opensearch.transport.grpc.proto.response.common.DocWriteResponseProtoUtils; + +/** + * Utility class for converting OpenSearch UpdateResponse to protobuf UpdateDocumentResponse. + *

+ * This class provides functionality similar to the REST layer's update response serialization. + * The response mapping and field serialization should be kept consistent with the corresponding + * REST XContent implementation to ensure feature parity between gRPC and HTTP APIs. + * + * @see org.opensearch.action.update.UpdateResponse#toXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent for response serialization + * @see org.opensearch.action.update.UpdateResponse#fromXContent(org.opensearch.core.xcontent.XContentParser) REST equivalent for response parsing + * @see org.opensearch.action.update.UpdateResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) Update-specific XContent serialization + * @see org.opensearch.action.update.UpdateResponse OpenSearch internal update response representation + * @see org.opensearch.protobufs.UpdateDocumentResponse Protobuf definition for gRPC update responses + */ +public class UpdateDocumentResponseProtoUtils { + + private UpdateDocumentResponseProtoUtils() { + // Utility class, no instances + } + + /** + * Converts an OpenSearch UpdateResponse to a protobuf UpdateDocumentResponse. + *

+ * This method serializes update response fields similar to how + * {@link org.opensearch.action.update.UpdateResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params)} + * serializes responses in REST. Field mapping includes index name, document ID, version, + * sequence number, primary term, result status, forced refresh, shard information, + * and optional get result for updated documents. + * + * @param updateResponse The OpenSearch UpdateResponse to convert + * @return The corresponding protobuf UpdateDocumentResponse + * @see org.opensearch.action.update.UpdateResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) REST equivalent + * @see org.opensearch.action.DocWriteResponse#innerToXContent(org.opensearch.core.xcontent.XContentBuilder, org.opensearch.core.xcontent.ToXContent.Params) Base class serialization + */ + public static UpdateDocumentResponse toProto(UpdateResponse updateResponse) { + UpdateDocumentResponseBody.Builder responseBodyBuilder = UpdateDocumentResponseBody.newBuilder() + .setXIndex(updateResponse.getIndex()) + .setXId(updateResponse.getId()) + .setXPrimaryTerm(updateResponse.getPrimaryTerm()) + .setXSeqNo(updateResponse.getSeqNo()) + .setXVersion(updateResponse.getVersion()) + .setResult(DocWriteResponseProtoUtils.resultToProto(updateResponse.getResult())); + + // Note: ShardInfo not available in UpdateDocumentResponseBody protobuf definition + + // Note: GetResult conversion skipped for now due to protobuf complexity + // This can be added later with proper GetResult protobuf mapping + + return UpdateDocumentResponse.newBuilder().setUpdateDocumentResponseBody(responseBodyBuilder.build()).build(); + } + + /** + * Creates an error response from an exception. + * + * @param exception The exception that occurred + * @param statusCode The HTTP status code + * @return The protobuf UpdateDocumentResponse with error details + */ + public static UpdateDocumentResponse toErrorProto(Exception exception, RestStatus statusCode) { + throw new UnsupportedOperationException("Use GrpcErrorHandler.convertToGrpcError() instead"); + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java index e1348bc78961f..d0985fce69ad1 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/services/DocumentServiceImpl.java @@ -10,9 +10,21 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.update.UpdateRequest; import org.opensearch.protobufs.services.DocumentServiceGrpc; import org.opensearch.transport.client.Client; import org.opensearch.transport.grpc.listeners.BulkRequestActionListener; +import org.opensearch.transport.grpc.listeners.DeleteDocumentActionListener; +import org.opensearch.transport.grpc.listeners.GetDocumentActionListener; +import org.opensearch.transport.grpc.listeners.IndexDocumentActionListener; +import org.opensearch.transport.grpc.listeners.UpdateDocumentActionListener; +import org.opensearch.transport.grpc.proto.request.document.DeleteDocumentRequestProtoUtils; +import org.opensearch.transport.grpc.proto.request.document.GetDocumentRequestProtoUtils; +import org.opensearch.transport.grpc.proto.request.document.IndexDocumentRequestProtoUtils; +import org.opensearch.transport.grpc.proto.request.document.UpdateDocumentRequestProtoUtils; import org.opensearch.transport.grpc.proto.request.document.bulk.BulkRequestProtoUtils; import org.opensearch.transport.grpc.util.GrpcErrorHandler; @@ -20,7 +32,12 @@ import io.grpc.stub.StreamObserver; /** - * Implementation of the gRPC Document Service. + * Implementation of the gRPC DocumentService. + * This class handles incoming gRPC document requests, converts them to OpenSearch requests, + * executes them using the provided client, and returns the results back to the gRPC client. + * + * This implementation uses direct client calls for true single document semantics, + * providing optimal performance and proper error handling for each operation type. */ public class DocumentServiceImpl extends DocumentServiceGrpc.DocumentServiceImplBase { private static final Logger logger = LogManager.getLogger(DocumentServiceImpl.class); @@ -32,6 +49,9 @@ public class DocumentServiceImpl extends DocumentServiceGrpc.DocumentServiceImpl * @param client Client for executing actions on the local node */ public DocumentServiceImpl(Client client) { + if (client == null) { + throw new IllegalArgumentException("Client cannot be null"); + } this.client = client; } @@ -48,7 +68,99 @@ public void bulk(org.opensearch.protobufs.BulkRequest request, StreamObserver responseObserver + ) { + try { + IndexRequest indexRequest = IndexDocumentRequestProtoUtils.fromProto(request); + IndexDocumentActionListener listener = new IndexDocumentActionListener(responseObserver); + client.index(indexRequest, listener); + } catch (RuntimeException e) { + logger.warn("DocumentServiceImpl indexDocument failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + /** + * Processes an update document request using direct client.update() call. + * This provides true single document update semantics with proper retry handling. + * + * @param request The update document request to process + * @param responseObserver The observer to send the response back to the client + */ + @Override + public void updateDocument( + org.opensearch.protobufs.UpdateDocumentRequest request, + StreamObserver responseObserver + ) { + try { + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(request); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + client.update(updateRequest, listener); + } catch (RuntimeException e) { + logger.warn("DocumentServiceImpl updateDocument failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + /** + * Processes a delete document request using direct client.delete() call. + * This provides true single document delete semantics. + * + * @param request The delete document request to process + * @param responseObserver The observer to send the response back to the client + */ + @Override + public void deleteDocument( + org.opensearch.protobufs.DeleteDocumentRequest request, + StreamObserver responseObserver + ) { + try { + DeleteRequest deleteRequest = DeleteDocumentRequestProtoUtils.fromProto(request); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + client.delete(deleteRequest, listener); + } catch (RuntimeException e) { + logger.warn("DocumentServiceImpl deleteDocument failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); + responseObserver.onError(grpcError); + } + } + + /** + * Processes a get document request using direct client.get() call. + * This provides true single document retrieval semantics. + * + * @param request The get document request to process + * @param responseObserver The observer to send the response back to the client + */ + @Override + public void getDocument( + org.opensearch.protobufs.GetDocumentRequest request, + StreamObserver responseObserver + ) { + try { + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(request); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + client.get(getRequest, listener); + } catch (RuntimeException e) { + logger.warn("DocumentServiceImpl getDocument failed: {} - {}", e.getClass().getSimpleName(), e.getMessage()); StatusRuntimeException grpcError = GrpcErrorHandler.convertToGrpcError(e); responseObserver.onError(grpcError); } diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/integration/DocumentServiceIntegrationTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/integration/DocumentServiceIntegrationTests.java new file mode 100644 index 0000000000000..badb66dab3897 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/integration/DocumentServiceIntegrationTests.java @@ -0,0 +1,611 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.integration; + +import com.google.protobuf.ByteString; +import org.opensearch.OpenSearchException; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.get.GetResponse; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.action.update.UpdateResponse; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.get.GetResult; +import org.opensearch.protobufs.DeleteDocumentRequest; +import org.opensearch.protobufs.DeleteDocumentResponse; +import org.opensearch.protobufs.GetDocumentRequest; +import org.opensearch.protobufs.GetDocumentResponse; +import org.opensearch.protobufs.IndexDocumentRequest; +import org.opensearch.protobufs.IndexDocumentResponse; +import org.opensearch.protobufs.UpdateDocumentRequest; +import org.opensearch.protobufs.UpdateDocumentRequestBody; +import org.opensearch.protobufs.UpdateDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.client.Client; +import org.opensearch.transport.grpc.services.DocumentServiceImpl; + +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for DocumentServiceImpl focusing on error handling and edge cases. + */ +public class DocumentServiceIntegrationTests extends OpenSearchTestCase { + + private Client mockClient; + private DocumentServiceImpl documentService; + + @Override + public void setUp() throws Exception { + super.setUp(); + mockClient = mock(Client.class); + documentService = new DocumentServiceImpl(mockClient); + } + + public void testIndexDocumentSuccessfulFlow() throws InterruptedException { + // Setup mock client to simulate successful index operation + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + IndexResponse response = new IndexResponse(shardId, "test-id", 1L, 2L, 3L, true); + listener.onResponse(response); + return null; + }).when(mockClient).index(any(IndexRequest.class), any()); + + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.indexDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNull("No error should occur", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("test-index", responseRef.get().getIndexDocumentResponseBody().getXIndex()); + assertEquals("test-id", responseRef.get().getIndexDocumentResponseBody().getXId()); + } + + public void testIndexDocumentWithOpenSearchException() throws InterruptedException { + // Setup mock client to simulate OpenSearch exception + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + OpenSearchException exception = new OpenSearchException("Index not found"); + listener.onFailure(exception); + return null; + }).when(mockClient).index(any(IndexRequest.class), any()); + + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("non-existent-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + fail("Should not receive response on error"); + } + + @Override + public void onError(Throwable t) { + if (t instanceof StatusRuntimeException) { + errorRef.set((StatusRuntimeException) t); + } + latch.countDown(); + } + + @Override + public void onCompleted() { + fail("Should not complete on error"); + } + }; + + documentService.indexDocument(request, responseObserver); + + assertTrue("Error should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNotNull("Error should be received", errorRef.get()); + assertTrue("Error message should contain exception details", errorRef.get().getMessage().contains("Index not found")); + } + + public void testUpdateDocumentSuccessfulFlow() throws InterruptedException { + // Setup mock client to simulate successful update operation + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse response = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + listener.onResponse(response); + return null; + }).when(mockClient).update(any(UpdateRequest.class), any()); + + UpdateDocumentRequest request = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody( + UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"updated_value\"}")).build() + ) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(UpdateDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.updateDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNull("No error should occur", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("test-index", responseRef.get().getUpdateDocumentResponseBody().getXIndex()); + assertEquals("test-id", responseRef.get().getUpdateDocumentResponseBody().getXId()); + assertEquals("updated", responseRef.get().getUpdateDocumentResponseBody().getResult()); + } + + public void testDeleteDocumentSuccessfulFlow() throws InterruptedException { + // Setup mock client to simulate successful delete operation + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse response = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + listener.onResponse(response); + return null; + }).when(mockClient).delete(any(DeleteRequest.class), any()); + + DeleteDocumentRequest request = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(DeleteDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.deleteDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNull("No error should occur", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("test-index", responseRef.get().getDeleteDocumentResponseBody().getXIndex()); + assertEquals("test-id", responseRef.get().getDeleteDocumentResponseBody().getXId()); + assertEquals("deleted", responseRef.get().getDeleteDocumentResponseBody().getResult()); + } + + public void testGetDocumentSuccessfulFlowFound() throws InterruptedException { + // Setup mock client to simulate successful get operation (document found) + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse response = new GetResponse(getResult); + listener.onResponse(response); + return null; + }).when(mockClient).get(any(GetRequest.class), any()); + + GetDocumentRequest request = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(GetDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.getDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNull("No error should occur", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("test-index", responseRef.get().getGetDocumentResponseBody().getXIndex()); + assertEquals("test-id", responseRef.get().getGetDocumentResponseBody().getXId()); + assertTrue("Document should be found", responseRef.get().getGetDocumentResponseBody().getFound()); + } + + public void testGetDocumentSuccessfulFlowNotFound() throws InterruptedException { + // Setup mock client to simulate successful get operation (document not found) + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + GetResult getResult = new GetResult( + "test-index", + "test-id", + -2L, + 0L, + -1L, // UNASSIGNED_SEQ_NO, UNASSIGNED_PRIMARY_TERM, NOT_FOUND + false, + null, + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse response = new GetResponse(getResult); + listener.onResponse(response); + return null; + }).when(mockClient).get(any(GetRequest.class), any()); + + GetDocumentRequest request = GetDocumentRequest.newBuilder().setIndex("test-index").setId("non-existent-id").build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(GetDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.getDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNull("No error should occur", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("test-index", responseRef.get().getGetDocumentResponseBody().getXIndex()); + assertEquals("test-id", responseRef.get().getGetDocumentResponseBody().getXId()); + assertFalse("Document should not be found", responseRef.get().getGetDocumentResponseBody().getFound()); + assertEquals("-2", responseRef.get().getGetDocumentResponseBody().getXSeqNo()); + assertEquals("-1", responseRef.get().getGetDocumentResponseBody().getXVersion()); + } + + public void testConcurrentOperations() throws InterruptedException { + // Setup mock client to simulate successful operations with delay + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + // Simulate some processing time + new Thread(() -> { + try { + Thread.sleep(100); + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + IndexResponse response = new IndexResponse(shardId, "test-id", 1L, 2L, 3L, true); + listener.onResponse(response); + } catch (InterruptedException e) { + listener.onFailure(e); + } + }).start(); + return null; + }).when(mockClient).index(any(IndexRequest.class), any()); + + int concurrentRequests = 10; + CountDownLatch latch = new CountDownLatch(concurrentRequests); + AtomicReference errorRef = new AtomicReference<>(); + + for (int i = 0; i < concurrentRequests; i++) { + final int requestId = i; + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id-" + requestId) + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value" + requestId + "\"}")) + .build(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + // Verify response + assertEquals("test-index", response.getIndexDocumentResponseBody().getXIndex()); + } + + @Override + public void onError(Throwable t) { + errorRef.set(new Exception("Request " + requestId + " failed", t)); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.indexDocument(request, responseObserver); + } + + assertTrue("All requests should complete within timeout", latch.await(10, TimeUnit.SECONDS)); + assertNull("No errors should occur during concurrent operations", errorRef.get()); + } + + public void testValidationErrorHandling() throws InterruptedException { + // Test with invalid request (missing index) + IndexDocumentRequest invalidRequest = IndexDocumentRequest.newBuilder() + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + fail("Should not receive response for invalid request"); + } + + @Override + public void onError(Throwable t) { + if (t instanceof StatusRuntimeException) { + errorRef.set((StatusRuntimeException) t); + } + latch.countDown(); + } + + @Override + public void onCompleted() { + fail("Should not complete for invalid request"); + } + }; + + documentService.indexDocument(invalidRequest, responseObserver); + + assertTrue("Error should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNotNull("Validation error should be received", errorRef.get()); + assertEquals("Should be INVALID_ARGUMENT status", Status.Code.INVALID_ARGUMENT, errorRef.get().getStatus().getCode()); + } + + public void testLargeDocumentHandling() throws InterruptedException { + // Setup mock client to handle large documents + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + IndexRequest request = invocation.getArgument(0); + + // Verify large document was received correctly + assertTrue("Large document should be properly handled", request.source().length() > 100000); + + ShardId shardId = new ShardId("large-doc-index", "test-uuid", 0); + IndexResponse response = new IndexResponse(shardId, "large-doc-id", 1L, 2L, 3L, true); + listener.onResponse(response); + return null; + }).when(mockClient).index(any(IndexRequest.class), any()); + + // Create a large document (1MB) + StringBuilder largeDoc = new StringBuilder(); + largeDoc.append("{\"data\":\""); + for (int i = 0; i < 100000; i++) { + largeDoc.append("0123456789"); + } + largeDoc.append("\"}"); + + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("large-doc-index") + .setId("large-doc-id") + .setBytesRequestBody(ByteString.copyFromUtf8(largeDoc.toString())) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.indexDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(10, TimeUnit.SECONDS)); + assertNull("No error should occur for large document", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("large-doc-index", responseRef.get().getIndexDocumentResponseBody().getXIndex()); + assertEquals("large-doc-id", responseRef.get().getIndexDocumentResponseBody().getXId()); + } + + public void testUnicodeDocumentHandling() throws InterruptedException { + // Setup mock client to handle Unicode documents + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + ShardId shardId = new ShardId("unicode-index", "test-uuid", 0); + IndexResponse response = new IndexResponse(shardId, "unicode-id", 1L, 2L, 3L, true); + listener.onResponse(response); + return null; + }).when(mockClient).index(any(IndexRequest.class), any()); + + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("测试索引") + .setId("测试文档ID") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"标题\":\"测试文档\",\"内容\":\"这是测试内容\",\"emoji\":\"🚀📚\"}")) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + responseRef.set(response); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + documentService.indexDocument(request, responseObserver); + + assertTrue("Response should be received within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNull("No error should occur for Unicode document", errorRef.get()); + assertNotNull("Response should be received", responseRef.get()); + assertEquals("unicode-index", responseRef.get().getIndexDocumentResponseBody().getXIndex()); + assertEquals("unicode-id", responseRef.get().getIndexDocumentResponseBody().getXId()); + } + + public void testTimeoutHandling() throws InterruptedException { + // Setup mock client to simulate timeout + doAnswer(invocation -> { + ActionListener listener = invocation.getArgument(1); + // Simulate timeout by not calling listener methods + new Thread(() -> { + try { + Thread.sleep(10000); // Long delay to simulate timeout + } catch (InterruptedException e) { + listener.onFailure(new RuntimeException("Timeout")); + } + }).start(); + return null; + }).when(mockClient).index(any(IndexRequest.class), any()); + + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setTimeout("1s") // Short timeout + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(IndexDocumentResponse response) { + fail("Should not receive response on timeout"); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + latch.countDown(); + } + + @Override + public void onCompleted() { + fail("Should not complete on timeout"); + } + }; + + documentService.indexDocument(request, responseObserver); + + // Wait a bit longer than the request timeout to ensure it's handled + assertTrue("Error should be received within timeout", latch.await(15, TimeUnit.SECONDS)); + assertNotNull("Timeout error should be received", errorRef.get()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListenerTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListenerTests.java new file mode 100644 index 0000000000000..b533ef6da55ed --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/DeleteDocumentActionListenerTests.java @@ -0,0 +1,322 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.protobufs.DeleteDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Comprehensive unit tests for DeleteDocumentActionListener. + */ +public class DeleteDocumentActionListenerTests extends OpenSearchTestCase { + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessDeleted() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + + listener.onResponse(deleteResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessNotFound() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 1L, false); + + listener.onResponse(deleteResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + Exception testException = new RuntimeException("Test error"); + + listener.onFailure(testException); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnFailureWithSpecificExceptions() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + // Test with IllegalArgumentException + IllegalArgumentException illegalArgException = new IllegalArgumentException("Invalid argument"); + listener.onFailure(illegalArgException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Reset mock + responseObserver = mock(StreamObserver.class); + listener = new DeleteDocumentActionListener(responseObserver); + + // Test with NullPointerException + NullPointerException nullPointerException = new NullPointerException("Null pointer"); + listener.onFailure(nullPointerException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithConversionError() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + // Create a mock response that will cause conversion issues + DeleteResponse mockResponse = mock(DeleteResponse.class); + when(mockResponse.getIndex()).thenThrow(new RuntimeException("Conversion error")); + + listener.onResponse(mockResponse); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithNullResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + listener.onResponse(null); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithVersionBoundaries() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test with maximum version values + DeleteResponse maxResponse = new DeleteResponse(shardId, "test-id", Long.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE, true); + listener.onResponse(maxResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Reset mock for minimum values test + responseObserver = mock(StreamObserver.class); + listener = new DeleteDocumentActionListener(responseObserver); + + // Test with minimum version values + DeleteResponse minResponse = new DeleteResponse(shardId, "test-id", 1L, 0L, 1L, false); + listener.onResponse(minResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithSpecialCharacters() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index-with-special_chars.and.dots", "uuid-123", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test:id/with\\special@characters#and$symbols%", 1L, 2L, 3L, true); + + listener.onResponse(deleteResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithUnicodeCharacters() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("测试索引", "测试UUID", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "测试文档ID", 1L, 2L, 3L, true); + + listener.onResponse(deleteResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + public void testConstructorWithNullObserver() { + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> new DeleteDocumentActionListener(null)); + assertEquals("Response observer cannot be null", exception.getMessage()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithEdgeCaseVersions() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test with UNASSIGNED_SEQ_NO (-2) and NOT_FOUND version (-1) + DeleteResponse edgeResponse = new DeleteResponse(shardId, "test-id", 1L, -2L, -1L, false); + listener.onResponse(edgeResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testMultipleCallsToOnResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + + // First call should succeed + listener.onResponse(deleteResponse); + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Second call should be ignored (stream already completed) + listener.onResponse(deleteResponse); + // Verify that onNext and onCompleted are not called again + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); // Still only once + verify(responseObserver).onCompleted(); // Still only once + } + + @SuppressWarnings("unchecked") + public void testOnFailureAfterOnResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + + // First call onResponse + listener.onResponse(deleteResponse); + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Then call onFailure - should be ignored + Exception testException = new RuntimeException("Test error"); + listener.onFailure(testException); + + // Verify onError is not called after stream is already completed + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseAfterOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + // First call onFailure + Exception testException = new RuntimeException("Test error"); + listener.onFailure(testException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Then call onResponse - should be ignored + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + listener.onResponse(deleteResponse); + + // Verify onNext and onCompleted are not called after error + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testMultipleOnFailureCalls() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + Exception testException1 = new RuntimeException("First error"); + Exception testException2 = new RuntimeException("Second error"); + + // First call should succeed + listener.onFailure(testException1); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Second call should be ignored + listener.onFailure(testException2); + // Verify onError is called only once + verify(responseObserver).onError(any(StatusRuntimeException.class)); // Still only once + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithVersionConflictScenario() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Simulate a version conflict scenario where document was already deleted + DeleteResponse conflictResponse = new DeleteResponse(shardId, "test-id", 3L, 5L, 2L, false); + listener.onResponse(conflictResponse); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithMultipleShardScenarios() { + StreamObserver responseObserver = mock(StreamObserver.class); + DeleteDocumentActionListener listener = new DeleteDocumentActionListener(responseObserver); + + // Test with different shard IDs from the same index + ShardId shard0 = new ShardId("multi-shard-index", "uuid-123", 0); + DeleteResponse response0 = new DeleteResponse(shard0, "doc-0", 1L, 1L, 1L, true); + + listener.onResponse(response0); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + + // Reset for next test + responseObserver = mock(StreamObserver.class); + listener = new DeleteDocumentActionListener(responseObserver); + + ShardId shard1 = new ShardId("multi-shard-index", "uuid-123", 1); + DeleteResponse response1 = new DeleteResponse(shard1, "doc-1", 1L, 2L, 1L, false); + + listener.onResponse(response1); + + verify(responseObserver).onNext(any(DeleteDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListenerTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListenerTests.java new file mode 100644 index 0000000000000..8cc8db81ad058 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/GetDocumentActionListenerTests.java @@ -0,0 +1,515 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.opensearch.action.get.GetResponse; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.index.get.GetResult; +import org.opensearch.protobufs.GetDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.Collections; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Comprehensive unit tests for GetDocumentActionListener. + */ +public class GetDocumentActionListenerTests extends OpenSearchTestCase { + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessFound() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, // seqNo + 2L, // primaryTerm + 3L, // version + true, // exists + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), // fields + Collections.emptyMap() // metaFields + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessNotFound() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + -2L, // UNASSIGNED_SEQ_NO + 0L, // UNASSIGNED_PRIMARY_TERM + -1L, // NOT_FOUND version + false, // exists + null, // source + Collections.emptyMap(), // fields + Collections.emptyMap() // metaFields + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + Exception testException = new RuntimeException("Test error"); + + listener.onFailure(testException); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnFailureWithSpecificExceptions() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // Test with IllegalArgumentException + IllegalArgumentException illegalArgException = new IllegalArgumentException("Invalid argument"); + listener.onFailure(illegalArgException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Reset mock + responseObserver = mock(StreamObserver.class); + listener = new GetDocumentActionListener(responseObserver); + + // Test with NullPointerException + NullPointerException nullPointerException = new NullPointerException("Null pointer"); + listener.onFailure(nullPointerException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithConversionError() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // Create a mock response that will cause conversion issues + GetResponse mockResponse = mock(GetResponse.class); + when(mockResponse.getIndex()).thenThrow(new RuntimeException("Conversion error")); + + listener.onResponse(mockResponse); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithNullResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + listener.onResponse(null); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithVersionBoundaries() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // Test with maximum version values + GetResult maxResult = new GetResult( + "test-index", + "test-id", + Long.MAX_VALUE, + Long.MAX_VALUE, + Long.MAX_VALUE, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse maxResponse = new GetResponse(maxResult); + listener.onResponse(maxResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Reset mock for minimum values test + responseObserver = mock(StreamObserver.class); + listener = new GetDocumentActionListener(responseObserver); + + // Test with minimum version values + GetResult minResult = new GetResult( + "test-index", + "test-id", + 0L, + 1L, + 1L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse minResponse = new GetResponse(minResult); + listener.onResponse(minResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithComplexSource() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + String complexJson = "{\"user\":{\"name\":\"John Doe\",\"age\":30},\"tags\":[\"important\",\"work\"]}"; + GetResult getResult = new GetResult( + "complex-index", + "complex-id", + 5L, + 3L, + 10L, + true, + new BytesArray(complexJson), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithEmptySource() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{}"), // Empty JSON object + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithNullSource() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + null, // null source + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithSpecialCharacters() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index-with-special_chars.and.dots", + "test:id/with\\special@characters#and$symbols%", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value with special chars: @#$%^&*()\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithUnicodeCharacters() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "测试索引", + "测试文档ID", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"标题\":\"测试文档\",\"内容\":\"这是测试内容\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + listener.onResponse(getResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + public void testConstructorWithNullObserver() { + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> new GetDocumentActionListener(null)); + assertEquals("Response observer cannot be null", exception.getMessage()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithEdgeCaseVersions() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // Test with UNASSIGNED_SEQ_NO (-2) and NOT_FOUND version (-1) + GetResult edgeResult = new GetResult( + "test-index", + "test-id", + -2L, // UNASSIGNED_SEQ_NO + 0L, // UNASSIGNED_PRIMARY_TERM + -1L, // NOT_FOUND + false, + null, + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse edgeResponse = new GetResponse(edgeResult); + listener.onResponse(edgeResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithLargeDocument() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // Create a large document (1MB) + StringBuilder largeContent = new StringBuilder(); + largeContent.append("{\"data\":\""); + for (int i = 0; i < 10000; i++) { + largeContent.append("0123456789"); + } + largeContent.append("\"}"); + + GetResult largeResult = new GetResult( + "large-doc-index", + "large-doc-id", + 1L, + 2L, + 3L, + true, + new BytesArray(largeContent.toString()), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse largeResponse = new GetResponse(largeResult); + + listener.onResponse(largeResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testMultipleCallsToOnResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + // First call should succeed + listener.onResponse(getResponse); + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Second call should be ignored (stream already completed) + listener.onResponse(getResponse); + // Verify that onNext and onCompleted are not called again + verify(responseObserver).onNext(any(GetDocumentResponse.class)); // Still only once + verify(responseObserver).onCompleted(); // Still only once + } + + @SuppressWarnings("unchecked") + public void testOnFailureAfterOnResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + + // First call onResponse + listener.onResponse(getResponse); + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Then call onFailure - should be ignored + Exception testException = new RuntimeException("Test error"); + listener.onFailure(testException); + + // Verify onError is not called after stream is already completed + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseAfterOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // First call onFailure + Exception testException = new RuntimeException("Test error"); + listener.onFailure(testException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Then call onResponse - should be ignored + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse getResponse = new GetResponse(getResult); + listener.onResponse(getResponse); + + // Verify onNext and onCompleted are not called after error + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testMultipleOnFailureCalls() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + Exception testException1 = new RuntimeException("First error"); + Exception testException2 = new RuntimeException("Second error"); + + // First call should succeed + listener.onFailure(testException1); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Second call should be ignored + listener.onFailure(testException2); + // Verify onError is called only once + verify(responseObserver).onError(any(StatusRuntimeException.class)); // Still only once + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithBinarySource() { + StreamObserver responseObserver = mock(StreamObserver.class); + GetDocumentActionListener listener = new GetDocumentActionListener(responseObserver); + + // Test with binary data in source + byte[] binaryData = new byte[] { 0x00, 0x01, 0x02, (byte) 0xFF, (byte) 0xFE }; + GetResult binaryResult = new GetResult( + "binary-index", + "binary-id", + 1L, + 2L, + 3L, + true, + new BytesArray(binaryData), + Collections.emptyMap(), + Collections.emptyMap() + ); + GetResponse binaryResponse = new GetResponse(binaryResult); + + listener.onResponse(binaryResponse); + + verify(responseObserver).onNext(any(GetDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListenerTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListenerTests.java new file mode 100644 index 0000000000000..eff0d089860ab --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/IndexDocumentActionListenerTests.java @@ -0,0 +1,68 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.opensearch.action.index.IndexResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.protobufs.IndexDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for IndexDocumentActionListener. + */ +public class IndexDocumentActionListenerTests extends OpenSearchTestCase { + + @SuppressWarnings("unchecked") + public void testOnResponseSuccess() { + StreamObserver responseObserver = mock(StreamObserver.class); + IndexDocumentActionListener listener = new IndexDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + IndexResponse indexResponse = new IndexResponse(shardId, "test-id", 1L, 2L, 3L, true); + + listener.onResponse(indexResponse); + + verify(responseObserver).onNext(any(IndexDocumentResponse.class)); + verify(responseObserver).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + IndexDocumentActionListener listener = new IndexDocumentActionListener(responseObserver); + + Exception testException = new RuntimeException("Test error"); + + listener.onFailure(testException); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithConversionError() { + StreamObserver responseObserver = mock(StreamObserver.class); + IndexDocumentActionListener listener = new IndexDocumentActionListener(responseObserver); + + // Create a mock response that will cause conversion issues + IndexResponse mockResponse = mock(IndexResponse.class); + when(mockResponse.getIndex()).thenThrow(new RuntimeException("Conversion error")); + + listener.onResponse(mockResponse); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListenerTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListenerTests.java new file mode 100644 index 0000000000000..711708290e490 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/listeners/UpdateDocumentActionListenerTests.java @@ -0,0 +1,328 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.listeners; + +import org.opensearch.action.update.UpdateResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.get.GetResult; +import org.opensearch.protobufs.UpdateDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Comprehensive unit tests for UpdateDocumentActionListener. + */ +public class UpdateDocumentActionListenerTests extends OpenSearchTestCase { + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessUpdated() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessCreated() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 1L, UpdateResponse.Result.CREATED); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessDeleted() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 4L, UpdateResponse.Result.DELETED); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseSuccessNoop() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.NOOP); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithGetResult() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + GetResult getResult = mock(GetResult.class); + when(getResult.isExists()).thenReturn(true); + when(getResult.getIndex()).thenReturn("test-index"); + when(getResult.getId()).thenReturn("test-id"); + when(getResult.getVersion()).thenReturn(3L); + updateResponse.setGetResult(getResult); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + Exception testException = new RuntimeException("Test error"); + + listener.onFailure(testException); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnFailureWithSpecificExceptions() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + // Test with IllegalArgumentException + IllegalArgumentException illegalArgException = new IllegalArgumentException("Invalid argument"); + listener.onFailure(illegalArgException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Reset mock + responseObserver = mock(StreamObserver.class); + listener = new UpdateDocumentActionListener(responseObserver); + + // Test with NullPointerException + NullPointerException nullPointerException = new NullPointerException("Null pointer"); + listener.onFailure(nullPointerException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithConversionError() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + // Create a mock response that will cause conversion issues + UpdateResponse mockResponse = mock(UpdateResponse.class); + when(mockResponse.getIndex()).thenThrow(new RuntimeException("Conversion error")); + + listener.onResponse(mockResponse); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithNullResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + listener.onResponse(null); + + verify(responseObserver).onError(any(StatusRuntimeException.class)); + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithVersionBoundaries() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test with maximum version values + UpdateResponse maxResponse = new UpdateResponse( + shardId, + "test-id", + Long.MAX_VALUE, + Long.MAX_VALUE, + Long.MAX_VALUE, + UpdateResponse.Result.UPDATED + ); + listener.onResponse(maxResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Reset mock for minimum values test + responseObserver = mock(StreamObserver.class); + listener = new UpdateDocumentActionListener(responseObserver); + + // Test with minimum version values + UpdateResponse minResponse = new UpdateResponse(shardId, "test-id", 1L, 0L, 1L, UpdateResponse.Result.CREATED); + listener.onResponse(minResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithSpecialCharacters() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index-with-special_chars.and.dots", "uuid-123", 0); + UpdateResponse updateResponse = new UpdateResponse( + shardId, + "test:id/with\\special@characters#and$symbols%", + 1L, + 2L, + 3L, + UpdateResponse.Result.UPDATED + ); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseWithUnicodeCharacters() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("测试索引", "测试UUID", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "测试文档ID", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + listener.onResponse(updateResponse); + + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + } + + public void testConstructorWithNullObserver() { + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> new UpdateDocumentActionListener(null)); + assertEquals("Response observer cannot be null", exception.getMessage()); + } + + @SuppressWarnings("unchecked") + public void testMultipleCallsToOnResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + // First call should succeed + listener.onResponse(updateResponse); + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Second call should be ignored (stream already completed) + listener.onResponse(updateResponse); + // Verify that onNext and onCompleted are not called again + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); // Still only once + verify(responseObserver).onCompleted(); // Still only once + } + + @SuppressWarnings("unchecked") + public void testOnFailureAfterOnResponse() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + // First call onResponse + listener.onResponse(updateResponse); + verify(responseObserver).onNext(any(UpdateDocumentResponse.class)); + verify(responseObserver).onCompleted(); + + // Then call onFailure - should be ignored + Exception testException = new RuntimeException("Test error"); + listener.onFailure(testException); + + // Verify onError is not called after stream is already completed + verify(responseObserver, never()).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testOnResponseAfterOnFailure() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + // First call onFailure + Exception testException = new RuntimeException("Test error"); + listener.onFailure(testException); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Then call onResponse - should be ignored + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + listener.onResponse(updateResponse); + + // Verify onNext and onCompleted are not called after error + verify(responseObserver, never()).onNext(any()); + verify(responseObserver, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + public void testMultipleOnFailureCalls() { + StreamObserver responseObserver = mock(StreamObserver.class); + UpdateDocumentActionListener listener = new UpdateDocumentActionListener(responseObserver); + + Exception testException1 = new RuntimeException("First error"); + Exception testException2 = new RuntimeException("Second error"); + + // First call should succeed + listener.onFailure(testException1); + verify(responseObserver).onError(any(StatusRuntimeException.class)); + + // Second call should be ignored + listener.onFailure(testException2); + // Verify onError is called only once + verify(responseObserver).onError(any(StatusRuntimeException.class)); // Still only once + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtilsTests.java new file mode 100644 index 0000000000000..dbc6d9bcc6fd8 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/DeleteDocumentRequestProtoUtilsTests.java @@ -0,0 +1,325 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.protobufs.DeleteDocumentRequest; +import org.opensearch.protobufs.Refresh; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Comprehensive unit tests for DeleteDocumentRequestProtoUtils. + */ +public class DeleteDocumentRequestProtoUtilsTests extends OpenSearchTestCase { + + public void testFromProtoBasicFields() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + DeleteRequest deleteRequest = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", deleteRequest.index()); + assertEquals("test-id", deleteRequest.id()); + } + + public void testFromProtoWithAllFields() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("test-routing") + .setRefresh(Refresh.REFRESH_TRUE) + .setIfSeqNo(5L) + .setIfPrimaryTerm(2L) + .setTimeout("30s") + .build(); + + DeleteRequest deleteRequest = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", deleteRequest.index()); + assertEquals("test-id", deleteRequest.id()); + assertEquals("test-routing", deleteRequest.routing()); + assertEquals(WriteRequest.RefreshPolicy.IMMEDIATE, deleteRequest.getRefreshPolicy()); + assertEquals(5L, deleteRequest.ifSeqNo()); + assertEquals(2L, deleteRequest.ifPrimaryTerm()); + assertEquals("30s", deleteRequest.timeout().toString()); + } + + public void testFromProtoMissingIndex() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder().setId("test-id").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> DeleteDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testFromProtoMissingId() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder().setIndex("test-index").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> DeleteDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Document ID is required for delete operations", exception.getMessage()); + } + + public void testFromProtoEmptyIndex() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder().setIndex("").setId("test-id").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> DeleteDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testFromProtoEmptyId() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> DeleteDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Document ID is required for delete operations", exception.getMessage()); + } + + public void testRefreshPolicyConversion() { + // Test REFRESH_TRUE + DeleteDocumentRequest protoRequest1 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRefresh(Refresh.REFRESH_TRUE) + .build(); + assertEquals(WriteRequest.RefreshPolicy.IMMEDIATE, DeleteDocumentRequestProtoUtils.fromProto(protoRequest1).getRefreshPolicy()); + + // Test REFRESH_WAIT_FOR + DeleteDocumentRequest protoRequest2 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRefresh(Refresh.REFRESH_WAIT_FOR) + .build(); + assertEquals(WriteRequest.RefreshPolicy.WAIT_UNTIL, DeleteDocumentRequestProtoUtils.fromProto(protoRequest2).getRefreshPolicy()); + + // Test REFRESH_FALSE (default) + DeleteDocumentRequest protoRequest3 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRefresh(Refresh.REFRESH_FALSE) + .build(); + assertEquals(WriteRequest.RefreshPolicy.NONE, DeleteDocumentRequestProtoUtils.fromProto(protoRequest3).getRefreshPolicy()); + + // Test unspecified refresh (should default to NONE) + DeleteDocumentRequest protoRequest4 = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + assertEquals(WriteRequest.RefreshPolicy.NONE, DeleteDocumentRequestProtoUtils.fromProto(protoRequest4).getRefreshPolicy()); + } + + public void testVersionConstraints() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setIfSeqNo(10L) + .setIfPrimaryTerm(5L) + .build(); + + DeleteRequest deleteRequest = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals(10L, deleteRequest.ifSeqNo()); + assertEquals(5L, deleteRequest.ifPrimaryTerm()); + } + + public void testVersionConstraintsBoundaries() { + // Test minimum values + DeleteDocumentRequest protoRequest1 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setIfSeqNo(0L) + .setIfPrimaryTerm(1L) + .build(); + DeleteRequest deleteRequest1 = DeleteDocumentRequestProtoUtils.fromProto(protoRequest1); + assertEquals(0L, deleteRequest1.ifSeqNo()); + assertEquals(1L, deleteRequest1.ifPrimaryTerm()); + + // Test large values + DeleteDocumentRequest protoRequest2 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setIfSeqNo(Long.MAX_VALUE) + .setIfPrimaryTerm(Long.MAX_VALUE) + .build(); + DeleteRequest deleteRequest2 = DeleteDocumentRequestProtoUtils.fromProto(protoRequest2); + assertEquals(Long.MAX_VALUE, deleteRequest2.ifSeqNo()); + assertEquals(Long.MAX_VALUE, deleteRequest2.ifPrimaryTerm()); + } + + public void testDefaultValues() { + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + DeleteRequest deleteRequest = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + + // Test default values + assertEquals(WriteRequest.RefreshPolicy.NONE, deleteRequest.getRefreshPolicy()); + assertEquals(-2L, deleteRequest.ifSeqNo()); // UNASSIGNED_SEQ_NO + assertEquals(0L, deleteRequest.ifPrimaryTerm()); // UNASSIGNED_PRIMARY_TERM + assertNull(deleteRequest.routing()); + } + + public void testRoutingValues() { + // Test with routing + DeleteDocumentRequest protoRequest1 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("user123") + .build(); + assertEquals("user123", DeleteDocumentRequestProtoUtils.fromProto(protoRequest1).routing()); + + // Test with empty routing (should be treated as null) + DeleteDocumentRequest protoRequest2 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("") + .build(); + assertNull(DeleteDocumentRequestProtoUtils.fromProto(protoRequest2).routing()); + + // Test without routing + DeleteDocumentRequest protoRequest3 = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + assertNull(DeleteDocumentRequestProtoUtils.fromProto(protoRequest3).routing()); + } + + public void testTimeoutValues() { + // Test with timeout + DeleteDocumentRequest protoRequest1 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setTimeout("5m") + .build(); + assertEquals("5m", DeleteDocumentRequestProtoUtils.fromProto(protoRequest1).timeout().toString()); + + // Test with different timeout formats + DeleteDocumentRequest protoRequest2 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setTimeout("30s") + .build(); + assertEquals("30s", DeleteDocumentRequestProtoUtils.fromProto(protoRequest2).timeout().toString()); + + // Test with milliseconds + DeleteDocumentRequest protoRequest3 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setTimeout("1000ms") + .build(); + assertEquals("1s", DeleteDocumentRequestProtoUtils.fromProto(protoRequest3).timeout().toString()); + + // Test with empty timeout (should use default) + DeleteDocumentRequest protoRequest4 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setTimeout("") + .build(); + assertNotNull(DeleteDocumentRequestProtoUtils.fromProto(protoRequest4).timeout()); + + // Test without timeout + DeleteDocumentRequest protoRequest5 = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + assertNotNull(DeleteDocumentRequestProtoUtils.fromProto(protoRequest5).timeout()); + } + + public void testSpecialCharactersInFields() { + // Test with special characters in index name + DeleteDocumentRequest protoRequest1 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index-with-dashes_and_underscores.and.dots") + .setId("test-id") + .build(); + assertEquals("test-index-with-dashes_and_underscores.and.dots", DeleteDocumentRequestProtoUtils.fromProto(protoRequest1).index()); + + // Test with special characters in ID + DeleteDocumentRequest protoRequest2 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test:id/with\\special@characters#and$symbols%") + .build(); + assertEquals("test:id/with\\special@characters#and$symbols%", DeleteDocumentRequestProtoUtils.fromProto(protoRequest2).id()); + + // Test with Unicode characters + DeleteDocumentRequest protoRequest3 = DeleteDocumentRequest.newBuilder() + .setIndex("测试索引") + .setId("测试文档ID") + .setRouting("用户路由") + .build(); + DeleteRequest deleteRequest3 = DeleteDocumentRequestProtoUtils.fromProto(protoRequest3); + assertEquals("测试索引", deleteRequest3.index()); + assertEquals("测试文档ID", deleteRequest3.id()); + assertEquals("用户路由", deleteRequest3.routing()); + } + + public void testNegativeVersionConstraints() { + // Test with negative sequence numbers (should be allowed for special values) + DeleteDocumentRequest protoRequest1 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setIfSeqNo(-1L) // NO_OPS_PERFORMED + .setIfPrimaryTerm(1L) + .build(); + assertEquals(-1L, DeleteDocumentRequestProtoUtils.fromProto(protoRequest1).ifSeqNo()); + + DeleteDocumentRequest protoRequest2 = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setIfSeqNo(-2L) // UNASSIGNED_SEQ_NO + .setIfPrimaryTerm(0L) + .build(); + assertEquals(-2L, DeleteDocumentRequestProtoUtils.fromProto(protoRequest2).ifSeqNo()); + } + + public void testComplexScenarios() { + // Test with all optional fields set to non-default values + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder() + .setIndex("complex-test-index") + .setId("complex-test-id") + .setRouting("shard-routing-key") + .setRefresh(Refresh.REFRESH_WAIT_FOR) + .setIfSeqNo(42L) + .setIfPrimaryTerm(7L) + .setTimeout("2m30s") + .build(); + + DeleteRequest deleteRequest = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("complex-test-index", deleteRequest.index()); + assertEquals("complex-test-id", deleteRequest.id()); + assertEquals("shard-routing-key", deleteRequest.routing()); + assertEquals(WriteRequest.RefreshPolicy.WAIT_UNTIL, deleteRequest.getRefreshPolicy()); + assertEquals(42L, deleteRequest.ifSeqNo()); + assertEquals(7L, deleteRequest.ifPrimaryTerm()); + assertEquals("2m30s", deleteRequest.timeout().toString()); + } + + public void testRequestConsistency() { + // Test that multiple conversions of the same proto request yield identical results + DeleteDocumentRequest protoRequest = DeleteDocumentRequest.newBuilder() + .setIndex("consistency-test") + .setId("doc-123") + .setRouting("routing-key") + .setRefresh(Refresh.REFRESH_TRUE) + .setIfSeqNo(100L) + .setIfPrimaryTerm(3L) + .setTimeout("45s") + .build(); + + DeleteRequest deleteRequest1 = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + DeleteRequest deleteRequest2 = DeleteDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals(deleteRequest1.index(), deleteRequest2.index()); + assertEquals(deleteRequest1.id(), deleteRequest2.id()); + assertEquals(deleteRequest1.routing(), deleteRequest2.routing()); + assertEquals(deleteRequest1.getRefreshPolicy(), deleteRequest2.getRefreshPolicy()); + assertEquals(deleteRequest1.ifSeqNo(), deleteRequest2.ifSeqNo()); + assertEquals(deleteRequest1.ifPrimaryTerm(), deleteRequest2.ifPrimaryTerm()); + assertEquals(deleteRequest1.timeout().toString(), deleteRequest2.timeout().toString()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtilsTests.java new file mode 100644 index 0000000000000..cd86cad97a12d --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/GetDocumentRequestProtoUtilsTests.java @@ -0,0 +1,459 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import org.opensearch.action.get.GetRequest; +import org.opensearch.protobufs.GetDocumentRequest; +import org.opensearch.search.fetch.subphase.FetchSourceContext; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Comprehensive unit tests for GetDocumentRequestProtoUtils. + */ +public class GetDocumentRequestProtoUtilsTests extends OpenSearchTestCase { + + public void testFromProtoBasicFields() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", getRequest.index()); + assertEquals("test-id", getRequest.id()); + } + + public void testFromProtoWithAllFields() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("test-routing") + .setPreference("_local") + .setRealtime(false) + .setRefresh(true) + .addXSourceIncludes("field1") + .addXSourceIncludes("field2") + .addXSourceExcludes("field3") + .addXSourceExcludes("field4") + .addStoredFields("stored1") + .addStoredFields("stored2") + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", getRequest.index()); + assertEquals("test-id", getRequest.id()); + assertEquals("test-routing", getRequest.routing()); + assertEquals("_local", getRequest.preference()); + assertFalse(getRequest.realtime()); + assertTrue(getRequest.refresh()); + + // Test source context + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + assertNotNull(sourceContext); + assertArrayEquals(new String[] { "field1", "field2" }, sourceContext.includes()); + assertArrayEquals(new String[] { "field3", "field4" }, sourceContext.excludes()); + + // Test stored fields + assertArrayEquals(new String[] { "stored1", "stored2" }, getRequest.storedFields()); + } + + public void testFromProtoMissingIndex() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setId("test-id").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> GetDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testFromProtoMissingId() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("test-index").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> GetDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Document ID is required for get operations", exception.getMessage()); + } + + public void testFromProtoEmptyIndex() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("").setId("test-id").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> GetDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testFromProtoEmptyId() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("test-index").setId("").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> GetDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Document ID is required for get operations", exception.getMessage()); + } + + public void testSourceContextIncludes() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceIncludes("title") + .addXSourceIncludes("content") + .addXSourceIncludes("metadata.*") + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + + assertNotNull(sourceContext); + assertArrayEquals(new String[] { "title", "content", "metadata.*" }, sourceContext.includes()); + assertNull(sourceContext.excludes()); + assertTrue(sourceContext.fetchSource()); + } + + public void testSourceContextExcludes() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceExcludes("password") + .addXSourceExcludes("internal.*") + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + + assertNotNull(sourceContext); + assertNull(sourceContext.includes()); + assertArrayEquals(new String[] { "password", "internal.*" }, sourceContext.excludes()); + assertTrue(sourceContext.fetchSource()); + } + + public void testSourceContextIncludesAndExcludes() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceIncludes("user.*") + .addXSourceIncludes("metadata") + .addXSourceExcludes("user.password") + .addXSourceExcludes("user.secret") + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + + assertNotNull(sourceContext); + assertArrayEquals(new String[] { "user.*", "metadata" }, sourceContext.includes()); + assertArrayEquals(new String[] { "user.password", "user.secret" }, sourceContext.excludes()); + assertTrue(sourceContext.fetchSource()); + } + + public void testEmptySourceContext() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + + // Should be null when no source filtering is specified + assertNull(sourceContext); + } + + public void testStoredFields() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addStoredFields("field1") + .addStoredFields("field2") + .addStoredFields("field3") + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + assertArrayEquals(new String[] { "field1", "field2", "field3" }, getRequest.storedFields()); + } + + public void testEmptyStoredFields() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + assertNull(getRequest.storedFields()); + } + + public void testDefaultValues() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + // Test default values + assertNull(getRequest.routing()); + assertNull(getRequest.preference()); + assertTrue(getRequest.realtime()); // Default is true + assertFalse(getRequest.refresh()); // Default is false + assertNull(getRequest.fetchSourceContext()); + assertNull(getRequest.storedFields()); + } + + public void testRealtimeAndRefreshCombinations() { + // Test realtime=true, refresh=false (default) + GetDocumentRequest protoRequest1 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRealtime(true) + .setRefresh(false) + .build(); + GetRequest getRequest1 = GetDocumentRequestProtoUtils.fromProto(protoRequest1); + assertTrue(getRequest1.realtime()); + assertFalse(getRequest1.refresh()); + + // Test realtime=false, refresh=true + GetDocumentRequest protoRequest2 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRealtime(false) + .setRefresh(true) + .build(); + GetRequest getRequest2 = GetDocumentRequestProtoUtils.fromProto(protoRequest2); + assertFalse(getRequest2.realtime()); + assertTrue(getRequest2.refresh()); + + // Test realtime=false, refresh=false + GetDocumentRequest protoRequest3 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRealtime(false) + .setRefresh(false) + .build(); + GetRequest getRequest3 = GetDocumentRequestProtoUtils.fromProto(protoRequest3); + assertFalse(getRequest3.realtime()); + assertFalse(getRequest3.refresh()); + } + + public void testPreferenceValues() { + // Test _local preference + GetDocumentRequest protoRequest1 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setPreference("_local") + .build(); + assertEquals("_local", GetDocumentRequestProtoUtils.fromProto(protoRequest1).preference()); + + // Test _primary preference + GetDocumentRequest protoRequest2 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setPreference("_primary") + .build(); + assertEquals("_primary", GetDocumentRequestProtoUtils.fromProto(protoRequest2).preference()); + + // Test custom node preference + GetDocumentRequest protoRequest3 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setPreference("node1,node2") + .build(); + assertEquals("node1,node2", GetDocumentRequestProtoUtils.fromProto(protoRequest3).preference()); + + // Test empty preference (should be treated as null) + GetDocumentRequest protoRequest4 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setPreference("") + .build(); + assertNull(GetDocumentRequestProtoUtils.fromProto(protoRequest4).preference()); + } + + public void testRoutingValues() { + // Test with routing + GetDocumentRequest protoRequest1 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("user123") + .build(); + assertEquals("user123", GetDocumentRequestProtoUtils.fromProto(protoRequest1).routing()); + + // Test with empty routing (should be treated as null) + GetDocumentRequest protoRequest2 = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").setRouting("").build(); + assertNull(GetDocumentRequestProtoUtils.fromProto(protoRequest2).routing()); + + // Test without routing + GetDocumentRequest protoRequest3 = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + assertNull(GetDocumentRequestProtoUtils.fromProto(protoRequest3).routing()); + } + + public void testSpecialCharactersInFields() { + // Test with special characters in index name + GetDocumentRequest protoRequest1 = GetDocumentRequest.newBuilder() + .setIndex("test-index-with-dashes_and_underscores.and.dots") + .setId("test-id") + .build(); + assertEquals("test-index-with-dashes_and_underscores.and.dots", GetDocumentRequestProtoUtils.fromProto(protoRequest1).index()); + + // Test with special characters in ID + GetDocumentRequest protoRequest2 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test:id/with\\special@characters#and$symbols%") + .build(); + assertEquals("test:id/with\\special@characters#and$symbols%", GetDocumentRequestProtoUtils.fromProto(protoRequest2).id()); + + // Test with Unicode characters + GetDocumentRequest protoRequest3 = GetDocumentRequest.newBuilder() + .setIndex("测试索引") + .setId("测试文档ID") + .setRouting("用户路由") + .setPreference("节点偏好") + .build(); + GetRequest getRequest3 = GetDocumentRequestProtoUtils.fromProto(protoRequest3); + assertEquals("测试索引", getRequest3.index()); + assertEquals("测试文档ID", getRequest3.id()); + assertEquals("用户路由", getRequest3.routing()); + assertEquals("节点偏好", getRequest3.preference()); + } + + public void testSourceFieldPatterns() { + // Test wildcard patterns + GetDocumentRequest protoRequest1 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceIncludes("user.*") + .addXSourceIncludes("metadata.*.public") + .addXSourceExcludes("*.password") + .addXSourceExcludes("*.secret") + .build(); + + GetRequest getRequest1 = GetDocumentRequestProtoUtils.fromProto(protoRequest1); + FetchSourceContext sourceContext1 = getRequest1.fetchSourceContext(); + + assertArrayEquals(new String[] { "user.*", "metadata.*.public" }, sourceContext1.includes()); + assertArrayEquals(new String[] { "*.password", "*.secret" }, sourceContext1.excludes()); + + // Test nested field patterns + GetDocumentRequest protoRequest2 = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceIncludes("user.profile.name") + .addXSourceIncludes("user.profile.email") + .addXSourceExcludes("user.profile.internal") + .build(); + + GetRequest getRequest2 = GetDocumentRequestProtoUtils.fromProto(protoRequest2); + FetchSourceContext sourceContext2 = getRequest2.fetchSourceContext(); + + assertArrayEquals(new String[] { "user.profile.name", "user.profile.email" }, sourceContext2.includes()); + assertArrayEquals(new String[] { "user.profile.internal" }, sourceContext2.excludes()); + } + + public void testDuplicateSourceFields() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceIncludes("field1") + .addXSourceIncludes("field2") + .addXSourceIncludes("field1") // Duplicate + .addXSourceExcludes("field3") + .addXSourceExcludes("field3") // Duplicate + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + + // Should preserve duplicates as they come from protobuf + assertArrayEquals(new String[] { "field1", "field2", "field1" }, sourceContext.includes()); + assertArrayEquals(new String[] { "field3", "field3" }, sourceContext.excludes()); + } + + public void testDuplicateStoredFields() { + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addStoredFields("field1") + .addStoredFields("field2") + .addStoredFields("field1") // Duplicate + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + // Should preserve duplicates as they come from protobuf + assertArrayEquals(new String[] { "field1", "field2", "field1" }, getRequest.storedFields()); + } + + public void testComplexScenarios() { + // Test with all optional fields set to non-default values + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("complex-test-index") + .setId("complex-test-id") + .setRouting("shard-routing-key") + .setPreference("_primary_first") + .setRealtime(false) + .setRefresh(true) + .addXSourceIncludes("user.*") + .addXSourceIncludes("metadata.public.*") + .addXSourceExcludes("user.password") + .addXSourceExcludes("user.secret_key") + .addXSourceExcludes("metadata.private.*") + .addStoredFields("timestamp") + .addStoredFields("version") + .addStoredFields("checksum") + .build(); + + GetRequest getRequest = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("complex-test-index", getRequest.index()); + assertEquals("complex-test-id", getRequest.id()); + assertEquals("shard-routing-key", getRequest.routing()); + assertEquals("_primary_first", getRequest.preference()); + assertFalse(getRequest.realtime()); + assertTrue(getRequest.refresh()); + + FetchSourceContext sourceContext = getRequest.fetchSourceContext(); + assertArrayEquals(new String[] { "user.*", "metadata.public.*" }, sourceContext.includes()); + assertArrayEquals(new String[] { "user.password", "user.secret_key", "metadata.private.*" }, sourceContext.excludes()); + + assertArrayEquals(new String[] { "timestamp", "version", "checksum" }, getRequest.storedFields()); + } + + public void testRequestConsistency() { + // Test that multiple conversions of the same proto request yield identical results + GetDocumentRequest protoRequest = GetDocumentRequest.newBuilder() + .setIndex("consistency-test") + .setId("doc-123") + .setRouting("routing-key") + .setPreference("_local") + .setRealtime(false) + .setRefresh(true) + .addXSourceIncludes("field1") + .addXSourceExcludes("field2") + .addStoredFields("stored1") + .build(); + + GetRequest getRequest1 = GetDocumentRequestProtoUtils.fromProto(protoRequest); + GetRequest getRequest2 = GetDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals(getRequest1.index(), getRequest2.index()); + assertEquals(getRequest1.id(), getRequest2.id()); + assertEquals(getRequest1.routing(), getRequest2.routing()); + assertEquals(getRequest1.preference(), getRequest2.preference()); + assertEquals(getRequest1.realtime(), getRequest2.realtime()); + assertEquals(getRequest1.refresh(), getRequest2.refresh()); + + // Compare source contexts + FetchSourceContext sourceContext1 = getRequest1.fetchSourceContext(); + FetchSourceContext sourceContext2 = getRequest2.fetchSourceContext(); + if (sourceContext1 != null && sourceContext2 != null) { + assertArrayEquals(sourceContext1.includes(), sourceContext2.includes()); + assertArrayEquals(sourceContext1.excludes(), sourceContext2.excludes()); + } else { + assertEquals(sourceContext1, sourceContext2); + } + + assertArrayEquals(getRequest1.storedFields(), getRequest2.storedFields()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtilsTests.java new file mode 100644 index 0000000000000..1e56e1791ec43 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/IndexDocumentRequestProtoUtilsTests.java @@ -0,0 +1,128 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import com.google.protobuf.ByteString; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.protobufs.IndexDocumentRequest; +import org.opensearch.protobufs.OpType; +import org.opensearch.protobufs.Refresh; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests for IndexDocumentRequestProtoUtils. + */ +public class IndexDocumentRequestProtoUtilsTests extends OpenSearchTestCase { + + public void testFromProtoBasicFields() { + IndexDocumentRequest protoRequest = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + IndexRequest indexRequest = IndexDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", indexRequest.index()); + assertEquals("test-id", indexRequest.id()); + assertNotNull(indexRequest.source()); + assertEquals(XContentType.JSON, indexRequest.getContentType()); + } + + public void testFromProtoWithAllFields() { + IndexDocumentRequest protoRequest = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setOpType(OpType.OP_TYPE_CREATE) + .setRouting("test-routing") + .setRefresh(Refresh.REFRESH_TRUE) + .setIfSeqNo(5L) + .setIfPrimaryTerm(2L) + .setPipeline("test-pipeline") + .setTimeout("30s") + .build(); + + IndexRequest indexRequest = IndexDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", indexRequest.index()); + assertEquals("test-id", indexRequest.id()); + assertEquals("test-routing", indexRequest.routing()); + assertEquals(WriteRequest.RefreshPolicy.IMMEDIATE, indexRequest.getRefreshPolicy()); + assertEquals(5L, indexRequest.ifSeqNo()); + assertEquals(2L, indexRequest.ifPrimaryTerm()); + assertEquals("test-pipeline", indexRequest.getPipeline()); + assertEquals("30s", indexRequest.timeout().toString()); + } + + public void testFromProtoMissingIndex() { + IndexDocumentRequest protoRequest = IndexDocumentRequest.newBuilder() + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> IndexDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testFromProtoMissingDocument() { + IndexDocumentRequest protoRequest = IndexDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> IndexDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Document content is required (use bytes_request_body field)", exception.getMessage()); + } + + public void testFromProtoObjectMapNotSupported() { + IndexDocumentRequest protoRequest = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody(org.opensearch.protobufs.ObjectMap.newBuilder().build()) + .build(); + + UnsupportedOperationException exception = expectThrows( + UnsupportedOperationException.class, + () -> IndexDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("ObjectMap request body not yet supported, use bytes_request_body", exception.getMessage()); + } + + public void testRefreshPolicyConversion() { + // Test REFRESH_TRUE + IndexDocumentRequest protoRequest1 = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setRefresh(Refresh.REFRESH_TRUE) + .build(); + assertEquals(WriteRequest.RefreshPolicy.IMMEDIATE, IndexDocumentRequestProtoUtils.fromProto(protoRequest1).getRefreshPolicy()); + + // Test REFRESH_WAIT_FOR + IndexDocumentRequest protoRequest2 = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setRefresh(Refresh.REFRESH_WAIT_FOR) + .build(); + assertEquals(WriteRequest.RefreshPolicy.WAIT_UNTIL, IndexDocumentRequestProtoUtils.fromProto(protoRequest2).getRefreshPolicy()); + + // Test REFRESH_FALSE (default) + IndexDocumentRequest protoRequest3 = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setRefresh(Refresh.REFRESH_FALSE) + .build(); + assertEquals(WriteRequest.RefreshPolicy.NONE, IndexDocumentRequestProtoUtils.fromProto(protoRequest3).getRefreshPolicy()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtilsTests.java new file mode 100644 index 0000000000000..0a9328d176667 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/UpdateDocumentRequestProtoUtilsTests.java @@ -0,0 +1,298 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.request.document; + +import com.google.protobuf.ByteString; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.protobufs.Refresh; +import org.opensearch.protobufs.UpdateDocumentRequest; +import org.opensearch.protobufs.UpdateDocumentRequestBody; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Comprehensive unit tests for UpdateDocumentRequestProtoUtils. + */ +public class UpdateDocumentRequestProtoUtilsTests extends OpenSearchTestCase { + + public void testFromProtoBasicFields() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", updateRequest.index()); + assertEquals("test-id", updateRequest.id()); + assertNotNull(updateRequest.doc()); + // Note: docContentType() method doesn't exist, this is handled internally + } + + public void testFromProtoWithAllFields() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("test-routing") + .setRefresh(Refresh.REFRESH_TRUE) + .setIfSeqNo(5L) + .setIfPrimaryTerm(2L) + .setTimeout("30s") + .setRetryOnConflict(3) + .setRequestBody( + UpdateDocumentRequestBody.newBuilder() + .setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"updated_value\"}")) + .setBytesUpsert(ByteString.copyFromUtf8("{\"field\":\"upsert_value\", \"created\":true}")) + .setDocAsUpsert(true) + .setDetectNoop(false) + .build() + ) + .build(); + + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", updateRequest.index()); + assertEquals("test-id", updateRequest.id()); + assertEquals("test-routing", updateRequest.routing()); + assertEquals(WriteRequest.RefreshPolicy.IMMEDIATE, updateRequest.getRefreshPolicy()); + assertEquals(5L, updateRequest.ifSeqNo()); + assertEquals(2L, updateRequest.ifPrimaryTerm()); + assertEquals("30s", updateRequest.timeout().toString()); + assertEquals(3, updateRequest.retryOnConflict()); + assertNotNull(updateRequest.doc()); + assertNotNull(updateRequest.upsertRequest()); + assertTrue(updateRequest.docAsUpsert()); + assertFalse(updateRequest.detectNoop()); + } + + public void testFromProtoWithScriptedUpsert() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody( + UpdateDocumentRequestBody.newBuilder() + .setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setScriptedUpsert(true) + .build() + ) + .build(); + + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals("test-index", updateRequest.index()); + assertEquals("test-id", updateRequest.id()); + assertTrue(updateRequest.scriptedUpsert()); + } + + public void testFromProtoMissingIndex() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setId("test-id") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testFromProtoMissingId() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Document ID is required for update operations", exception.getMessage()); + } + + public void testFromProtoMissingRequestBody() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Update request body is required", exception.getMessage()); + } + + public void testFromProtoEmptyRequestBody() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().build()) + .build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Update document content is required (use bytes_doc field)", exception.getMessage()); + } + + public void testRefreshPolicyConversion() { + // Test REFRESH_TRUE + UpdateDocumentRequest protoRequest1 = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRefresh(Refresh.REFRESH_TRUE) + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + assertEquals(WriteRequest.RefreshPolicy.IMMEDIATE, UpdateDocumentRequestProtoUtils.fromProto(protoRequest1).getRefreshPolicy()); + + // Test REFRESH_WAIT_FOR + UpdateDocumentRequest protoRequest2 = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRefresh(Refresh.REFRESH_WAIT_FOR) + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + assertEquals(WriteRequest.RefreshPolicy.WAIT_UNTIL, UpdateDocumentRequestProtoUtils.fromProto(protoRequest2).getRefreshPolicy()); + + // Test REFRESH_FALSE (default) + UpdateDocumentRequest protoRequest3 = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRefresh(Refresh.REFRESH_FALSE) + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + assertEquals(WriteRequest.RefreshPolicy.NONE, UpdateDocumentRequestProtoUtils.fromProto(protoRequest3).getRefreshPolicy()); + } + + public void testVersionConstraints() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setIfSeqNo(10L) + .setIfPrimaryTerm(5L) + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + + assertEquals(10L, updateRequest.ifSeqNo()); + assertEquals(5L, updateRequest.ifPrimaryTerm()); + } + + public void testRetryOnConflictBoundaries() { + // Test minimum value + UpdateDocumentRequest protoRequest1 = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRetryOnConflict(0) + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + assertEquals(0, UpdateDocumentRequestProtoUtils.fromProto(protoRequest1).retryOnConflict()); + + // Test maximum reasonable value + UpdateDocumentRequest protoRequest2 = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRetryOnConflict(10) + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + assertEquals(10, UpdateDocumentRequestProtoUtils.fromProto(protoRequest2).retryOnConflict()); + } + + public void testUpsertOnlyRequest() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody( + UpdateDocumentRequestBody.newBuilder() + .setBytesUpsert(ByteString.copyFromUtf8("{\"field\":\"upsert_only\", \"created\":true}")) + .build() + ) + .build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Update document content is required (use bytes_doc field)", exception.getMessage()); + } + + public void testInvalidJsonInDoc() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("invalid json")).build()) + .build(); + + // Should not throw exception during conversion - OpenSearch will handle invalid JSON + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + assertNotNull(updateRequest); + assertEquals("test-index", updateRequest.index()); + assertEquals("test-id", updateRequest.id()); + } + + public void testEmptyStringsHandling() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("") + .setId("") + .setRouting("") + .setTimeout("") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentRequestProtoUtils.fromProto(protoRequest) + ); + assertEquals("Index name is required", exception.getMessage()); + } + + public void testNullAndDefaultValues() { + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")).build()) + .build(); + + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + + // Test default values + assertEquals(WriteRequest.RefreshPolicy.NONE, updateRequest.getRefreshPolicy()); + assertEquals(-2L, updateRequest.ifSeqNo()); // UNASSIGNED_SEQ_NO + assertEquals(0L, updateRequest.ifPrimaryTerm()); // UNASSIGNED_PRIMARY_TERM + assertEquals(0, updateRequest.retryOnConflict()); + assertNull(updateRequest.routing()); + assertNull(updateRequest.upsertRequest()); + assertTrue(updateRequest.detectNoop()); // Default is true + assertFalse(updateRequest.docAsUpsert()); // Default is false + assertFalse(updateRequest.scriptedUpsert()); // Default is false + } + + public void testLargeDocument() { + // Test with a large document (1MB) + StringBuilder largeDoc = new StringBuilder(); + largeDoc.append("{\"data\":\""); + for (int i = 0; i < 100000; i++) { + largeDoc.append("0123456789"); + } + largeDoc.append("\"}"); + + UpdateDocumentRequest protoRequest = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRequestBody(UpdateDocumentRequestBody.newBuilder().setBytesDoc(ByteString.copyFromUtf8(largeDoc.toString())).build()) + .build(); + + UpdateRequest updateRequest = UpdateDocumentRequestProtoUtils.fromProto(protoRequest); + assertNotNull(updateRequest); + assertEquals("test-index", updateRequest.index()); + assertEquals("test-id", updateRequest.id()); + assertTrue(updateRequest.doc().source().length() > 1000000); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtilsTests.java new file mode 100644 index 0000000000000..6bc2daf0ba291 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/DeleteDocumentResponseProtoUtilsTests.java @@ -0,0 +1,215 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.protobufs.DeleteDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Comprehensive unit tests for DeleteDocumentResponseProtoUtils. + */ +public class DeleteDocumentResponseProtoUtilsTests extends OpenSearchTestCase { + + public void testToProtoBasicFields() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + + DeleteDocumentResponse protoResponse = DeleteDocumentResponseProtoUtils.toProto(deleteResponse); + + assertEquals("test-index", protoResponse.getDeleteDocumentResponseBody().getXIndex()); + assertEquals("test-id", protoResponse.getDeleteDocumentResponseBody().getXId()); + assertEquals("1", protoResponse.getDeleteDocumentResponseBody().getXPrimaryTerm()); + assertEquals("2", protoResponse.getDeleteDocumentResponseBody().getXSeqNo()); + assertEquals("3", protoResponse.getDeleteDocumentResponseBody().getXVersion()); + assertEquals("deleted", protoResponse.getDeleteDocumentResponseBody().getResult()); + } + + public void testToProtoDeletedAndNotFound() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test successful deletion + DeleteResponse deletedResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + DeleteDocumentResponse deletedProto = DeleteDocumentResponseProtoUtils.toProto(deletedResponse); + assertEquals("deleted", deletedProto.getDeleteDocumentResponseBody().getResult()); + + // Test document not found + DeleteResponse notFoundResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 1L, false); + DeleteDocumentResponse notFoundProto = DeleteDocumentResponseProtoUtils.toProto(notFoundResponse); + assertEquals("not_found", notFoundProto.getDeleteDocumentResponseBody().getResult()); + } + + public void testToProtoVersionBoundaries() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test minimum version + DeleteResponse response1 = new DeleteResponse(shardId, "test-id", 1L, 0L, 1L, true); + DeleteDocumentResponse proto1 = DeleteDocumentResponseProtoUtils.toProto(response1); + assertEquals("0", proto1.getDeleteDocumentResponseBody().getXSeqNo()); + assertEquals("1", proto1.getDeleteDocumentResponseBody().getXVersion()); + + // Test large version numbers + DeleteResponse response2 = new DeleteResponse(shardId, "test-id", Long.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE, true); + DeleteDocumentResponse proto2 = DeleteDocumentResponseProtoUtils.toProto(response2); + assertEquals(String.valueOf(Long.MAX_VALUE), proto2.getDeleteDocumentResponseBody().getXPrimaryTerm()); + assertEquals(String.valueOf(Long.MAX_VALUE), proto2.getDeleteDocumentResponseBody().getXSeqNo()); + assertEquals(String.valueOf(Long.MAX_VALUE), proto2.getDeleteDocumentResponseBody().getXVersion()); + } + + public void testToProtoSpecialCharactersInFields() { + ShardId shardId = new ShardId("test-index-with-dashes_and_underscores.and.dots", "test-uuid-123", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test:id/with\\special@characters#and$symbols%", 1L, 2L, 3L, true); + + DeleteDocumentResponse protoResponse = DeleteDocumentResponseProtoUtils.toProto(deleteResponse); + + assertEquals("test-index-with-dashes_and_underscores.and.dots", protoResponse.getDeleteDocumentResponseBody().getXIndex()); + assertEquals("test:id/with\\special@characters#and$symbols%", protoResponse.getDeleteDocumentResponseBody().getXId()); + } + + public void testToProtoUnicodeCharacters() { + ShardId shardId = new ShardId("测试索引", "测试UUID", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "测试文档ID", 1L, 2L, 3L, true); + + DeleteDocumentResponse protoResponse = DeleteDocumentResponseProtoUtils.toProto(deleteResponse); + + assertEquals("测试索引", protoResponse.getDeleteDocumentResponseBody().getXIndex()); + assertEquals("测试文档ID", protoResponse.getDeleteDocumentResponseBody().getXId()); + } + + public void testToProtoConsistency() { + ShardId shardId = new ShardId("consistency-test", "uuid-123", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "doc-456", 5L, 10L, 15L, true); + + DeleteDocumentResponse proto1 = DeleteDocumentResponseProtoUtils.toProto(deleteResponse); + DeleteDocumentResponse proto2 = DeleteDocumentResponseProtoUtils.toProto(deleteResponse); + + // Test that multiple conversions yield identical results + assertEquals(proto1.getDeleteDocumentResponseBody().getXIndex(), proto2.getDeleteDocumentResponseBody().getXIndex()); + assertEquals(proto1.getDeleteDocumentResponseBody().getXId(), proto2.getDeleteDocumentResponseBody().getXId()); + assertEquals(proto1.getDeleteDocumentResponseBody().getXPrimaryTerm(), proto2.getDeleteDocumentResponseBody().getXPrimaryTerm()); + assertEquals(proto1.getDeleteDocumentResponseBody().getXSeqNo(), proto2.getDeleteDocumentResponseBody().getXSeqNo()); + assertEquals(proto1.getDeleteDocumentResponseBody().getXVersion(), proto2.getDeleteDocumentResponseBody().getXVersion()); + assertEquals(proto1.getDeleteDocumentResponseBody().getResult(), proto2.getDeleteDocumentResponseBody().getResult()); + } + + public void testToProtoNullDeleteResponse() { + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> DeleteDocumentResponseProtoUtils.toProto(null) + ); + assertEquals("DeleteResponse cannot be null", exception.getMessage()); + } + + public void testToProtoShardIdHandling() { + // Test different shard configurations + ShardId shardId1 = new ShardId("index1", "uuid1", 0); + DeleteResponse response1 = new DeleteResponse(shardId1, "id1", 1L, 1L, 1L, true); + DeleteDocumentResponse proto1 = DeleteDocumentResponseProtoUtils.toProto(response1); + assertEquals("index1", proto1.getDeleteDocumentResponseBody().getXIndex()); + + ShardId shardId2 = new ShardId("index2", "uuid2", 5); + DeleteResponse response2 = new DeleteResponse(shardId2, "id2", 2L, 2L, 2L, false); + DeleteDocumentResponse proto2 = DeleteDocumentResponseProtoUtils.toProto(response2); + assertEquals("index2", proto2.getDeleteDocumentResponseBody().getXIndex()); + } + + public void testToProtoResultMapping() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test deleted result + DeleteResponse deletedResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + DeleteDocumentResponse deletedProto = DeleteDocumentResponseProtoUtils.toProto(deletedResponse); + assertEquals("deleted", deletedProto.getDeleteDocumentResponseBody().getResult()); + + // Test not_found result + DeleteResponse notFoundResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 1L, false); + DeleteDocumentResponse notFoundProto = DeleteDocumentResponseProtoUtils.toProto(notFoundResponse); + assertEquals("not_found", notFoundProto.getDeleteDocumentResponseBody().getResult()); + } + + public void testToProtoResponseStructure() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + DeleteResponse deleteResponse = new DeleteResponse(shardId, "test-id", 1L, 2L, 3L, true); + + DeleteDocumentResponse protoResponse = DeleteDocumentResponseProtoUtils.toProto(deleteResponse); + + // Verify the response structure + assertNotNull(protoResponse); + assertNotNull(protoResponse.getDeleteDocumentResponseBody()); + + // Verify all required fields are present + assertFalse(protoResponse.getDeleteDocumentResponseBody().getXIndex().isEmpty()); + assertFalse(protoResponse.getDeleteDocumentResponseBody().getXId().isEmpty()); + // Note: These are primitive fields, not objects, so no isEmpty() method + } + + public void testToProtoVersionConstraintScenarios() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test scenario where document was already deleted (version conflict) + DeleteResponse conflictResponse = new DeleteResponse(shardId, "test-id", 3L, 5L, 2L, false); + DeleteDocumentResponse conflictProto = DeleteDocumentResponseProtoUtils.toProto(conflictResponse); + assertEquals("not_found", conflictProto.getDeleteDocumentResponseBody().getResult()); + assertEquals("3", conflictProto.getDeleteDocumentResponseBody().getXPrimaryTerm()); + assertEquals("5", conflictProto.getDeleteDocumentResponseBody().getXSeqNo()); + assertEquals("2", conflictProto.getDeleteDocumentResponseBody().getXVersion()); + + // Test successful deletion with high version numbers + DeleteResponse successResponse = new DeleteResponse(shardId, "test-id", 10L, 20L, 15L, true); + DeleteDocumentResponse successProto = DeleteDocumentResponseProtoUtils.toProto(successResponse); + assertEquals("deleted", successProto.getDeleteDocumentResponseBody().getResult()); + assertEquals("10", successProto.getDeleteDocumentResponseBody().getXPrimaryTerm()); + assertEquals("20", successProto.getDeleteDocumentResponseBody().getXSeqNo()); + assertEquals("15", successProto.getDeleteDocumentResponseBody().getXVersion()); + } + + public void testToProtoMultipleShardScenarios() { + // Test responses from different shards of the same index + ShardId shard0 = new ShardId("multi-shard-index", "uuid-123", 0); + ShardId shard1 = new ShardId("multi-shard-index", "uuid-123", 1); + ShardId shard2 = new ShardId("multi-shard-index", "uuid-123", 2); + + DeleteResponse response0 = new DeleteResponse(shard0, "doc-0", 1L, 1L, 1L, true); + DeleteResponse response1 = new DeleteResponse(shard1, "doc-1", 1L, 2L, 1L, true); + DeleteResponse response2 = new DeleteResponse(shard2, "doc-2", 1L, 3L, 1L, false); + + DeleteDocumentResponse proto0 = DeleteDocumentResponseProtoUtils.toProto(response0); + DeleteDocumentResponse proto1 = DeleteDocumentResponseProtoUtils.toProto(response1); + DeleteDocumentResponse proto2 = DeleteDocumentResponseProtoUtils.toProto(response2); + + // All should have the same index name + assertEquals("multi-shard-index", proto0.getDeleteDocumentResponseBody().getXIndex()); + assertEquals("multi-shard-index", proto1.getDeleteDocumentResponseBody().getXIndex()); + assertEquals("multi-shard-index", proto2.getDeleteDocumentResponseBody().getXIndex()); + + // But different document IDs and results + assertEquals("doc-0", proto0.getDeleteDocumentResponseBody().getXId()); + assertEquals("doc-1", proto1.getDeleteDocumentResponseBody().getXId()); + assertEquals("doc-2", proto2.getDeleteDocumentResponseBody().getXId()); + + assertEquals("deleted", proto0.getDeleteDocumentResponseBody().getResult()); + assertEquals("deleted", proto1.getDeleteDocumentResponseBody().getResult()); + assertEquals("not_found", proto2.getDeleteDocumentResponseBody().getResult()); + } + + public void testToProtoEdgeCaseVersions() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test with version 0 (should not happen in practice but test boundary) + DeleteResponse response1 = new DeleteResponse(shardId, "test-id", 1L, 1L, 0L, true); + DeleteDocumentResponse proto1 = DeleteDocumentResponseProtoUtils.toProto(response1); + assertEquals("0", proto1.getDeleteDocumentResponseBody().getXVersion()); + + // Test with negative sequence number (UNASSIGNED_SEQ_NO = -2) + DeleteResponse response2 = new DeleteResponse(shardId, "test-id", 1L, -2L, 1L, false); + DeleteDocumentResponse proto2 = DeleteDocumentResponseProtoUtils.toProto(response2); + assertEquals("-2", proto2.getDeleteDocumentResponseBody().getXSeqNo()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtilsTests.java new file mode 100644 index 0000000000000..df9276c3a5994 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/GetDocumentResponseProtoUtilsTests.java @@ -0,0 +1,382 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.get.GetResponse; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.index.get.GetResult; +import org.opensearch.protobufs.GetDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Comprehensive unit tests for GetDocumentResponseProtoUtils. + */ +public class GetDocumentResponseProtoUtilsTests extends OpenSearchTestCase { + + public void testToProtoDocumentFound() { + // Create a GetResult for a found document + Map sourceMap = new HashMap<>(); + sourceMap.put("title", "Test Document"); + sourceMap.put("content", "This is test content"); + + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, // seqNo + 2L, // primaryTerm + 3L, // version + true, // exists + new BytesArray("{\"title\":\"Test Document\",\"content\":\"This is test content\"}"), + Collections.emptyMap(), // fields + Collections.emptyMap() // metaFields + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertEquals("test-index", protoResponse.getGetDocumentResponseBody().getXIndex()); + assertEquals("test-id", protoResponse.getGetDocumentResponseBody().getXId()); + assertEquals("1", protoResponse.getGetDocumentResponseBody().getXSeqNo()); + assertEquals("2", protoResponse.getGetDocumentResponseBody().getXPrimaryTerm()); + assertEquals("3", protoResponse.getGetDocumentResponseBody().getXVersion()); + assertTrue(protoResponse.getGetDocumentResponseBody().getFound()); + assertFalse(protoResponse.getGetDocumentResponseBody().getXSource().isEmpty()); + } + + public void testToProtoDocumentNotFound() { + // Create a GetResult for a document that doesn't exist + GetResult getResult = new GetResult( + "test-index", + "test-id", + -2L, // UNASSIGNED_SEQ_NO + 0L, // UNASSIGNED_PRIMARY_TERM + -1L, // NOT_FOUND version + false, // exists + null, // source + Collections.emptyMap(), // fields + Collections.emptyMap() // metaFields + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertEquals("test-index", protoResponse.getGetDocumentResponseBody().getXIndex()); + assertEquals("test-id", protoResponse.getGetDocumentResponseBody().getXId()); + assertEquals("-2", protoResponse.getGetDocumentResponseBody().getXSeqNo()); + assertEquals("0", protoResponse.getGetDocumentResponseBody().getXPrimaryTerm()); + assertEquals("-1", protoResponse.getGetDocumentResponseBody().getXVersion()); + assertFalse(protoResponse.getGetDocumentResponseBody().getFound()); + assertTrue(protoResponse.getGetDocumentResponseBody().getXSource().isEmpty()); + } + + public void testToProtoWithComplexSource() { + // Create a complex JSON document + String complexJson = + "{\"user\":{\"name\":\"John Doe\",\"age\":30,\"preferences\":{\"theme\":\"dark\",\"language\":\"en\"}},\"tags\":[\"important\",\"work\"],\"metadata\":{\"created\":\"2025-01-01\",\"modified\":\"2025-01-02\"}}"; + + GetResult getResult = new GetResult( + "complex-index", + "complex-id", + 5L, // seqNo + 3L, // primaryTerm + 10L, // version + true, // exists + new BytesArray(complexJson), + Collections.emptyMap(), // fields + Collections.emptyMap() // metaFields + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertEquals("complex-index", protoResponse.getGetDocumentResponseBody().getXIndex()); + assertEquals("complex-id", protoResponse.getGetDocumentResponseBody().getXId()); + assertTrue(protoResponse.getGetDocumentResponseBody().getFound()); + assertEquals(complexJson, protoResponse.getGetDocumentResponseBody().getXSource().toStringUtf8()); + } + + public void testToProtoVersionBoundaries() { + // Test minimum values + GetResult getResult1 = new GetResult( + "test-index", + "test-id", + 0L, // minimum seqNo + 1L, // minimum primaryTerm + 1L, // minimum version + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse1 = new GetResponse(getResult1); + GetDocumentResponse protoResponse1 = GetDocumentResponseProtoUtils.toProto(getResponse1); + assertEquals("0", protoResponse1.getGetDocumentResponseBody().getXSeqNo()); + assertEquals("1", protoResponse1.getGetDocumentResponseBody().getXPrimaryTerm()); + assertEquals("1", protoResponse1.getGetDocumentResponseBody().getXVersion()); + + // Test maximum values + GetResult getResult2 = new GetResult( + "test-index", + "test-id", + Long.MAX_VALUE, + Long.MAX_VALUE, + Long.MAX_VALUE, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse2 = new GetResponse(getResult2); + GetDocumentResponse protoResponse2 = GetDocumentResponseProtoUtils.toProto(getResponse2); + assertEquals(String.valueOf(Long.MAX_VALUE), protoResponse2.getGetDocumentResponseBody().getXSeqNo()); + assertEquals(String.valueOf(Long.MAX_VALUE), protoResponse2.getGetDocumentResponseBody().getXPrimaryTerm()); + assertEquals(String.valueOf(Long.MAX_VALUE), protoResponse2.getGetDocumentResponseBody().getXVersion()); + } + + public void testToProtoSpecialCharactersInFields() { + GetResult getResult = new GetResult( + "test-index-with-dashes_and_underscores.and.dots", + "test:id/with\\special@characters#and$symbols%", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value with special chars: @#$%^&*()\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertEquals("test-index-with-dashes_and_underscores.and.dots", protoResponse.getGetDocumentResponseBody().getXIndex()); + assertEquals("test:id/with\\special@characters#and$symbols%", protoResponse.getGetDocumentResponseBody().getXId()); + } + + public void testToProtoUnicodeCharacters() { + GetResult getResult = new GetResult( + "测试索引", + "测试文档ID", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"标题\":\"测试文档\",\"内容\":\"这是测试内容\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertEquals("测试索引", protoResponse.getGetDocumentResponseBody().getXIndex()); + assertEquals("测试文档ID", protoResponse.getGetDocumentResponseBody().getXId()); + assertEquals("{\"标题\":\"测试文档\",\"内容\":\"这是测试内容\"}", protoResponse.getGetDocumentResponseBody().getXSource().toStringUtf8()); + } + + public void testToProtoEmptySource() { + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{}"), // Empty JSON object + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertTrue(protoResponse.getGetDocumentResponseBody().getFound()); + assertEquals("{}", protoResponse.getGetDocumentResponseBody().getXSource().toStringUtf8()); + } + + public void testToProtoNullSource() { + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + null, // null source + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertTrue(protoResponse.getGetDocumentResponseBody().getFound()); + assertTrue(protoResponse.getGetDocumentResponseBody().getXSource().isEmpty()); + } + + public void testToProtoConsistency() { + GetResult getResult = new GetResult( + "consistency-test", + "doc-456", + 5L, + 10L, + 15L, + true, + new BytesArray("{\"consistency\":\"test\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse proto1 = GetDocumentResponseProtoUtils.toProto(getResponse); + GetDocumentResponse proto2 = GetDocumentResponseProtoUtils.toProto(getResponse); + + // Test that multiple conversions yield identical results + assertEquals(proto1.getGetDocumentResponseBody().getXIndex(), proto2.getGetDocumentResponseBody().getXIndex()); + assertEquals(proto1.getGetDocumentResponseBody().getXId(), proto2.getGetDocumentResponseBody().getXId()); + assertEquals(proto1.getGetDocumentResponseBody().getXSeqNo(), proto2.getGetDocumentResponseBody().getXSeqNo()); + assertEquals(proto1.getGetDocumentResponseBody().getXPrimaryTerm(), proto2.getGetDocumentResponseBody().getXPrimaryTerm()); + assertEquals(proto1.getGetDocumentResponseBody().getXVersion(), proto2.getGetDocumentResponseBody().getXVersion()); + assertEquals(proto1.getGetDocumentResponseBody().getFound(), proto2.getGetDocumentResponseBody().getFound()); + assertEquals(proto1.getGetDocumentResponseBody().getXSource(), proto2.getGetDocumentResponseBody().getXSource()); + } + + public void testToProtoNullGetResponse() { + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> GetDocumentResponseProtoUtils.toProto(null) + ); + assertEquals("GetResponse cannot be null", exception.getMessage()); + } + + public void testToProtoResponseStructure() { + GetResult getResult = new GetResult( + "test-index", + "test-id", + 1L, + 2L, + 3L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + // Verify the response structure + assertNotNull(protoResponse); + assertNotNull(protoResponse.getGetDocumentResponseBody()); + + // Verify all required fields are present + assertFalse(protoResponse.getGetDocumentResponseBody().getXIndex().isEmpty()); + assertFalse(protoResponse.getGetDocumentResponseBody().getXId().isEmpty()); + // Note: These are primitive fields, not objects, so no isEmpty() method + } + + public void testToProtoLargeDocument() { + // Test with a large document (1MB) + StringBuilder largeContent = new StringBuilder(); + largeContent.append("{\"data\":\""); + for (int i = 0; i < 100000; i++) { + largeContent.append("0123456789"); + } + largeContent.append("\"}"); + + GetResult getResult = new GetResult( + "large-doc-index", + "large-doc-id", + 1L, + 2L, + 3L, + true, + new BytesArray(largeContent.toString()), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertTrue(protoResponse.getGetDocumentResponseBody().getFound()); + assertTrue(protoResponse.getGetDocumentResponseBody().getXSource().size() > 1000000); + assertEquals(largeContent.toString(), protoResponse.getGetDocumentResponseBody().getXSource().toStringUtf8()); + } + + public void testToProtoEdgeCaseVersions() { + // Test with special version values used by OpenSearch + GetResult getResult1 = new GetResult( + "test-index", + "test-id", + -2L, // UNASSIGNED_SEQ_NO + 0L, // UNASSIGNED_PRIMARY_TERM + -1L, // NOT_FOUND + false, + null, + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse1 = new GetResponse(getResult1); + GetDocumentResponse protoResponse1 = GetDocumentResponseProtoUtils.toProto(getResponse1); + assertEquals("-2", protoResponse1.getGetDocumentResponseBody().getXSeqNo()); + assertEquals("0", protoResponse1.getGetDocumentResponseBody().getXPrimaryTerm()); + assertEquals("-1", protoResponse1.getGetDocumentResponseBody().getXVersion()); + assertFalse(protoResponse1.getGetDocumentResponseBody().getFound()); + + // Test with NO_OPS_PERFORMED (-1) + GetResult getResult2 = new GetResult( + "test-index", + "test-id", + -1L, // NO_OPS_PERFORMED + 1L, + 1L, + true, + new BytesArray("{\"field\":\"value\"}"), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse2 = new GetResponse(getResult2); + GetDocumentResponse protoResponse2 = GetDocumentResponseProtoUtils.toProto(getResponse2); + assertEquals("-1", protoResponse2.getGetDocumentResponseBody().getXSeqNo()); + assertTrue(protoResponse2.getGetDocumentResponseBody().getFound()); + } + + public void testToProtoSourceEncodingEdgeCases() { + // Test with binary data in source (should be handled as bytes) + byte[] binaryData = new byte[] { 0x00, 0x01, 0x02, (byte) 0xFF, (byte) 0xFE }; + + GetResult getResult = new GetResult( + "binary-index", + "binary-id", + 1L, + 2L, + 3L, + true, + new BytesArray(binaryData), + Collections.emptyMap(), + Collections.emptyMap() + ); + + GetResponse getResponse = new GetResponse(getResult); + GetDocumentResponse protoResponse = GetDocumentResponseProtoUtils.toProto(getResponse); + + assertTrue(protoResponse.getGetDocumentResponseBody().getFound()); + assertEquals(binaryData.length, protoResponse.getGetDocumentResponseBody().getXSource().size()); + assertArrayEquals(binaryData, protoResponse.getGetDocumentResponseBody().getXSource().toByteArray()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtilsTests.java new file mode 100644 index 0000000000000..6f885a2f2d745 --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/IndexDocumentResponseProtoUtilsTests.java @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.support.replication.ReplicationResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.protobufs.IndexDocumentResponse; +import org.opensearch.protobufs.Result; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests for IndexDocumentResponseProtoUtils. + */ +public class IndexDocumentResponseProtoUtilsTests extends OpenSearchTestCase { + + public void testToProtoBasicFields() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + IndexResponse indexResponse = new IndexResponse(shardId, "test-id", 1L, 2L, 3L, true); + + IndexDocumentResponse protoResponse = IndexDocumentResponseProtoUtils.toProto(indexResponse); + + assertTrue(protoResponse.hasIndexDocumentResponseBody()); + assertEquals("test-index", protoResponse.getIndexDocumentResponseBody().getXIndex()); + assertEquals("test-id", protoResponse.getIndexDocumentResponseBody().getXId()); + assertEquals(1L, protoResponse.getIndexDocumentResponseBody().getXSeqNo()); + assertEquals(2L, protoResponse.getIndexDocumentResponseBody().getXPrimaryTerm()); + assertEquals(3L, protoResponse.getIndexDocumentResponseBody().getXVersion()); + assertEquals(Result.RESULT_CREATED, protoResponse.getIndexDocumentResponseBody().getResult()); + } + + public void testToProtoWithUpdatedResult() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + IndexResponse indexResponse = new IndexResponse(shardId, "test-id", 1L, 2L, 3L, false); + + IndexDocumentResponse protoResponse = IndexDocumentResponseProtoUtils.toProto(indexResponse); + + assertEquals(Result.RESULT_UPDATED, protoResponse.getIndexDocumentResponseBody().getResult()); + } + + public void testToProtoWithShardInfo() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + IndexResponse indexResponse = new IndexResponse(shardId, "test-id", 1L, 2L, 3L, true); + + // Set shard info + ReplicationResponse.ShardInfo shardInfo = new ReplicationResponse.ShardInfo(2, 1, new ReplicationResponse.ShardInfo.Failure[0]); + indexResponse.setShardInfo(shardInfo); + + IndexDocumentResponse protoResponse = IndexDocumentResponseProtoUtils.toProto(indexResponse); + + assertTrue(protoResponse.hasIndexDocumentResponseBody()); + // Note: ShardInfo conversion is skipped in current implementation + // This test verifies the method doesn't fail when ShardInfo is present + } + + public void testErrorProtoThrowsException() { + Exception testException = new RuntimeException("Test error"); + + UnsupportedOperationException exception = expectThrows( + UnsupportedOperationException.class, + () -> IndexDocumentResponseProtoUtils.toErrorProto(testException, org.opensearch.core.rest.RestStatus.BAD_REQUEST) + ); + assertEquals("Use GrpcErrorHandler.convertToGrpcError() instead", exception.getMessage()); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtilsTests.java new file mode 100644 index 0000000000000..99411a775aded --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/document/UpdateDocumentResponseProtoUtilsTests.java @@ -0,0 +1,198 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.proto.response.document; + +import org.opensearch.action.update.UpdateResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.get.GetResult; +import org.opensearch.protobufs.UpdateDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Comprehensive unit tests for UpdateDocumentResponseProtoUtils. + */ +public class UpdateDocumentResponseProtoUtilsTests extends OpenSearchTestCase { + + public void testToProtoBasicFields() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + assertEquals("test-index", protoResponse.getUpdateDocumentResponseBody().getXIndex()); + assertEquals("test-id", protoResponse.getUpdateDocumentResponseBody().getXId()); + assertEquals("1", protoResponse.getUpdateDocumentResponseBody().getXPrimaryTerm()); + assertEquals("2", protoResponse.getUpdateDocumentResponseBody().getXSeqNo()); + assertEquals("3", protoResponse.getUpdateDocumentResponseBody().getXVersion()); + assertEquals("updated", protoResponse.getUpdateDocumentResponseBody().getResult()); + } + + public void testToProtoAllResults() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test UPDATED result + UpdateResponse updatedResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + UpdateDocumentResponse updatedProto = UpdateDocumentResponseProtoUtils.toProto(updatedResponse); + assertEquals("updated", updatedProto.getUpdateDocumentResponseBody().getResult()); + + // Test CREATED result + UpdateResponse createdResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 1L, UpdateResponse.Result.CREATED); + UpdateDocumentResponse createdProto = UpdateDocumentResponseProtoUtils.toProto(createdResponse); + assertEquals("created", createdProto.getUpdateDocumentResponseBody().getResult()); + + // Test DELETED result + UpdateResponse deletedResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 4L, UpdateResponse.Result.DELETED); + UpdateDocumentResponse deletedProto = UpdateDocumentResponseProtoUtils.toProto(deletedResponse); + assertEquals("deleted", deletedProto.getUpdateDocumentResponseBody().getResult()); + + // Test NOOP result + UpdateResponse noopResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.NOOP); + UpdateDocumentResponse noopProto = UpdateDocumentResponseProtoUtils.toProto(noopResponse); + assertEquals("noop", noopProto.getUpdateDocumentResponseBody().getResult()); + } + + public void testToProtoWithGetResult() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + GetResult getResult = mock(GetResult.class); + when(getResult.isExists()).thenReturn(true); + when(getResult.getIndex()).thenReturn("test-index"); + when(getResult.getId()).thenReturn("test-id"); + when(getResult.getVersion()).thenReturn(5L); + + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + updateResponse.setGetResult(getResult); + + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + assertEquals("test-index", protoResponse.getUpdateDocumentResponseBody().getXIndex()); + assertEquals("test-id", protoResponse.getUpdateDocumentResponseBody().getXId()); + assertEquals("updated", protoResponse.getUpdateDocumentResponseBody().getResult()); + // Note: GetResult conversion is skipped in current implementation + } + + public void testToProtoVersionBoundaries() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Test minimum version + UpdateResponse response1 = new UpdateResponse(shardId, "test-id", 1L, 0L, 1L, UpdateResponse.Result.CREATED); + UpdateDocumentResponse proto1 = UpdateDocumentResponseProtoUtils.toProto(response1); + assertEquals("0", proto1.getUpdateDocumentResponseBody().getXSeqNo()); + assertEquals("1", proto1.getUpdateDocumentResponseBody().getXVersion()); + + // Test large version numbers + UpdateResponse response2 = new UpdateResponse( + shardId, + "test-id", + Long.MAX_VALUE, + Long.MAX_VALUE, + Long.MAX_VALUE, + UpdateResponse.Result.UPDATED + ); + UpdateDocumentResponse proto2 = UpdateDocumentResponseProtoUtils.toProto(response2); + assertEquals(String.valueOf(Long.MAX_VALUE), proto2.getUpdateDocumentResponseBody().getXPrimaryTerm()); + assertEquals(String.valueOf(Long.MAX_VALUE), proto2.getUpdateDocumentResponseBody().getXSeqNo()); + assertEquals(String.valueOf(Long.MAX_VALUE), proto2.getUpdateDocumentResponseBody().getXVersion()); + } + + public void testToProtoSpecialCharactersInFields() { + ShardId shardId = new ShardId("test-index-with-dashes_and_underscores.and.dots", "test-uuid-123", 0); + UpdateResponse updateResponse = new UpdateResponse( + shardId, + "test:id/with\\special@characters#and$symbols%", + 1L, + 2L, + 3L, + UpdateResponse.Result.UPDATED + ); + + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + assertEquals("test-index-with-dashes_and_underscores.and.dots", protoResponse.getUpdateDocumentResponseBody().getXIndex()); + assertEquals("test:id/with\\special@characters#and$symbols%", protoResponse.getUpdateDocumentResponseBody().getXId()); + } + + public void testToProtoUnicodeCharacters() { + ShardId shardId = new ShardId("测试索引", "测试UUID", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "测试文档ID", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + assertEquals("测试索引", protoResponse.getUpdateDocumentResponseBody().getXIndex()); + assertEquals("测试文档ID", protoResponse.getUpdateDocumentResponseBody().getXId()); + } + + public void testToProtoConsistency() { + ShardId shardId = new ShardId("consistency-test", "uuid-123", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "doc-456", 5L, 10L, 15L, UpdateResponse.Result.UPDATED); + + UpdateDocumentResponse proto1 = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + UpdateDocumentResponse proto2 = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + // Test that multiple conversions yield identical results + assertEquals(proto1.getUpdateDocumentResponseBody().getXIndex(), proto2.getUpdateDocumentResponseBody().getXIndex()); + assertEquals(proto1.getUpdateDocumentResponseBody().getXId(), proto2.getUpdateDocumentResponseBody().getXId()); + assertEquals(proto1.getUpdateDocumentResponseBody().getXPrimaryTerm(), proto2.getUpdateDocumentResponseBody().getXPrimaryTerm()); + assertEquals(proto1.getUpdateDocumentResponseBody().getXSeqNo(), proto2.getUpdateDocumentResponseBody().getXSeqNo()); + assertEquals(proto1.getUpdateDocumentResponseBody().getXVersion(), proto2.getUpdateDocumentResponseBody().getXVersion()); + assertEquals(proto1.getUpdateDocumentResponseBody().getResult(), proto2.getUpdateDocumentResponseBody().getResult()); + } + + public void testToProtoNullUpdateResponse() { + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> UpdateDocumentResponseProtoUtils.toProto(null) + ); + assertEquals("UpdateResponse cannot be null", exception.getMessage()); + } + + public void testToProtoShardIdHandling() { + // Test different shard configurations + ShardId shardId1 = new ShardId("index1", "uuid1", 0); + UpdateResponse response1 = new UpdateResponse(shardId1, "id1", 1L, 1L, 1L, UpdateResponse.Result.CREATED); + UpdateDocumentResponse proto1 = UpdateDocumentResponseProtoUtils.toProto(response1); + assertEquals("index1", proto1.getUpdateDocumentResponseBody().getXIndex()); + + ShardId shardId2 = new ShardId("index2", "uuid2", 5); + UpdateResponse response2 = new UpdateResponse(shardId2, "id2", 2L, 2L, 2L, UpdateResponse.Result.UPDATED); + UpdateDocumentResponse proto2 = UpdateDocumentResponseProtoUtils.toProto(response2); + assertEquals("index2", proto2.getUpdateDocumentResponseBody().getXIndex()); + } + + public void testToProtoResultMapping() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + + // Verify all result types are properly mapped + for (UpdateResponse.Result result : UpdateResponse.Result.values()) { + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, result); + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + String expectedResult = result.getLowercase(); + assertEquals(expectedResult, protoResponse.getUpdateDocumentResponseBody().getResult()); + } + } + + public void testToProtoResponseStructure() { + ShardId shardId = new ShardId("test-index", "test-uuid", 0); + UpdateResponse updateResponse = new UpdateResponse(shardId, "test-id", 1L, 2L, 3L, UpdateResponse.Result.UPDATED); + + UpdateDocumentResponse protoResponse = UpdateDocumentResponseProtoUtils.toProto(updateResponse); + + // Verify the response structure + assertNotNull(protoResponse); + assertNotNull(protoResponse.getUpdateDocumentResponseBody()); + + // Verify all required fields are present + assertFalse(protoResponse.getUpdateDocumentResponseBody().getXIndex().isEmpty()); + assertFalse(protoResponse.getUpdateDocumentResponseBody().getXId().isEmpty()); + // Note: These are primitive fields, not objects, so no isEmpty() method + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/services/DocumentServiceImplTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/services/DocumentServiceImplTests.java new file mode 100644 index 0000000000000..e849f0c57243b --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/services/DocumentServiceImplTests.java @@ -0,0 +1,565 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.transport.grpc.services; + +import com.google.protobuf.ByteString; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.core.action.ActionListener; +import org.opensearch.protobufs.DeleteDocumentRequest; +import org.opensearch.protobufs.DeleteDocumentResponse; +import org.opensearch.protobufs.GetDocumentRequest; +import org.opensearch.protobufs.GetDocumentResponse; +import org.opensearch.protobufs.IndexDocumentRequest; +import org.opensearch.protobufs.IndexDocumentResponse; +import org.opensearch.protobufs.UpdateDocumentRequest; +import org.opensearch.protobufs.UpdateDocumentResponse; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.client.Client; + +import io.grpc.stub.StreamObserver; +import org.mockito.ArgumentCaptor; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for DocumentServiceImpl. + */ +public class DocumentServiceImplTests extends OpenSearchTestCase { + + private Client mockClient; + private DocumentServiceImpl documentService; + + @Override + public void setUp() throws Exception { + super.setUp(); + mockClient = mock(Client.class); + documentService = new DocumentServiceImpl(mockClient); + } + + public void testConstructorWithNullClient() { + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> new DocumentServiceImpl(null)); + assertEquals("Client cannot be null", exception.getMessage()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testIndexDocument() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).index(requestCaptor.capture(), listenerCaptor.capture()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + assertNotNull(capturedRequest.source()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testUpdateDocument() { + UpdateDocumentRequest request = UpdateDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.updateDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UpdateRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).update(requestCaptor.capture(), listenerCaptor.capture()); + + UpdateRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testDeleteDocument() { + DeleteDocumentRequest request = DeleteDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.deleteDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).delete(requestCaptor.capture(), listenerCaptor.capture()); + + DeleteRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testGetDocument() { + GetDocumentRequest request = GetDocumentRequest.newBuilder().setIndex("test-index").setId("test-id").build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.getDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(GetRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).get(requestCaptor.capture(), listenerCaptor.capture()); + + GetRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + } + + @SuppressWarnings("unchecked") + public void testIndexDocumentWithException() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder().setIndex("test-index").build(); // Missing required fields + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + // Should call onError due to validation failure + verify(responseObserver).onError(any()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testIndexDocumentWithAllOptionalFields() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .setOpType(org.opensearch.protobufs.OpType.OP_TYPE_CREATE) + .setRouting("test-routing") + .setRefresh(org.opensearch.protobufs.Refresh.REFRESH_TRUE) + .setIfSeqNo(5L) + .setIfPrimaryTerm(2L) + .setPipeline("test-pipeline") + .setTimeout("30s") + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).index(requestCaptor.capture(), listenerCaptor.capture()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + assertEquals("test-routing", capturedRequest.routing()); + assertEquals("test-pipeline", capturedRequest.getPipeline()); + assertNotNull(capturedRequest.source()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testUpdateDocumentWithAllOptionalFields() { + UpdateDocumentRequest request = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("test-routing") + .setRefresh(org.opensearch.protobufs.Refresh.REFRESH_WAIT_FOR) + .setIfSeqNo(10L) + .setIfPrimaryTerm(3L) + .setTimeout("45s") + .setRetryOnConflict(5) + .setRequestBody( + org.opensearch.protobufs.UpdateDocumentRequestBody.newBuilder() + .setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"updated_value\"}")) + .setBytesUpsert(ByteString.copyFromUtf8("{\"field\":\"upsert_value\"}")) + .setDocAsUpsert(true) + .setDetectNoop(false) + .setScriptedUpsert(true) + .build() + ) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.updateDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UpdateRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).update(requestCaptor.capture(), listenerCaptor.capture()); + + UpdateRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + assertEquals("test-routing", capturedRequest.routing()); + assertEquals(5, capturedRequest.retryOnConflict()); + assertTrue(capturedRequest.docAsUpsert()); + assertFalse(capturedRequest.detectNoop()); + assertTrue(capturedRequest.scriptedUpsert()); + } + + @SuppressWarnings("unchecked") + public void testUpdateDocumentWithException() { + UpdateDocumentRequest request = UpdateDocumentRequest.newBuilder().setIndex("test-index").build(); // Missing required fields + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.updateDocument(request, responseObserver); + + // Should call onError due to validation failure + verify(responseObserver).onError(any()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testDeleteDocumentWithAllOptionalFields() { + DeleteDocumentRequest request = DeleteDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("test-routing") + .setRefresh(org.opensearch.protobufs.Refresh.REFRESH_TRUE) + .setIfSeqNo(15L) + .setIfPrimaryTerm(4L) + .setTimeout("60s") + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.deleteDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).delete(requestCaptor.capture(), listenerCaptor.capture()); + + DeleteRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + assertEquals("test-routing", capturedRequest.routing()); + assertEquals(15L, capturedRequest.ifSeqNo()); + assertEquals(4L, capturedRequest.ifPrimaryTerm()); + } + + @SuppressWarnings("unchecked") + public void testDeleteDocumentWithException() { + DeleteDocumentRequest request = DeleteDocumentRequest.newBuilder().setIndex("test-index").build(); // Missing required fields + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.deleteDocument(request, responseObserver); + + // Should call onError due to validation failure + verify(responseObserver).onError(any()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testGetDocumentWithAllOptionalFields() { + GetDocumentRequest request = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setRouting("test-routing") + .setPreference("_local") + .setRealtime(false) + .setRefresh(true) + .addXSourceIncludes("field1") + .addXSourceIncludes("field2") + .addXSourceExcludes("field3") + .addStoredFields("stored1") + .addStoredFields("stored2") + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.getDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(GetRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + + verify(mockClient).get(requestCaptor.capture(), listenerCaptor.capture()); + + GetRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + assertEquals("test-routing", capturedRequest.routing()); + assertEquals("_local", capturedRequest.preference()); + assertFalse(capturedRequest.realtime()); + assertTrue(capturedRequest.refresh()); + assertNotNull(capturedRequest.fetchSourceContext()); + assertNotNull(capturedRequest.storedFields()); + } + + @SuppressWarnings("unchecked") + public void testGetDocumentWithException() { + GetDocumentRequest request = GetDocumentRequest.newBuilder().setIndex("test-index").build(); // Missing required fields + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.getDocument(request, responseObserver); + + // Should call onError due to validation failure + verify(responseObserver).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testIndexDocumentWithEmptyFields() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("") + .setId("") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + // Should call onError due to empty index name + verify(responseObserver).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testUpdateDocumentWithEmptyFields() { + UpdateDocumentRequest request = UpdateDocumentRequest.newBuilder() + .setIndex("") + .setId("") + .setRequestBody( + org.opensearch.protobufs.UpdateDocumentRequestBody.newBuilder() + .setBytesDoc(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build() + ) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.updateDocument(request, responseObserver); + + // Should call onError due to empty index name + verify(responseObserver).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testDeleteDocumentWithEmptyFields() { + DeleteDocumentRequest request = DeleteDocumentRequest.newBuilder().setIndex("").setId("").build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.deleteDocument(request, responseObserver); + + // Should call onError due to empty index name + verify(responseObserver).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testGetDocumentWithEmptyFields() { + GetDocumentRequest request = GetDocumentRequest.newBuilder().setIndex("").setId("").build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.getDocument(request, responseObserver); + + // Should call onError due to empty index name + verify(responseObserver).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testIndexDocumentWithSpecialCharacters() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index-with-special_chars.and.dots") + .setId("test:id/with\\special@characters#and$symbols%") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value with special chars: @#$%^&*()\"}")) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(mockClient).index(requestCaptor.capture(), any()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index-with-special_chars.and.dots", capturedRequest.index()); + assertEquals("test:id/with\\special@characters#and$symbols%", capturedRequest.id()); + } + + @SuppressWarnings("unchecked") + public void testIndexDocumentWithUnicodeCharacters() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("测试索引") + .setId("测试文档ID") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"标题\":\"测试文档\",\"内容\":\"这是测试内容\"}")) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(mockClient).index(requestCaptor.capture(), any()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("测试索引", capturedRequest.index()); + assertEquals("测试文档ID", capturedRequest.id()); + } + + @SuppressWarnings("unchecked") + public void testIndexDocumentWithLargeDocument() { + // Create a large document (100KB) + StringBuilder largeDoc = new StringBuilder(); + largeDoc.append("{\"data\":\""); + for (int i = 0; i < 10000; i++) { + largeDoc.append("0123456789"); + } + largeDoc.append("\"}"); + + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("large-doc-index") + .setId("large-doc-id") + .setBytesRequestBody(ByteString.copyFromUtf8(largeDoc.toString())) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(mockClient).index(requestCaptor.capture(), any()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("large-doc-index", capturedRequest.index()); + assertEquals("large-doc-id", capturedRequest.id()); + assertTrue(capturedRequest.source().length() > 100000); + } + + @SuppressWarnings("unchecked") + public void testIndexDocumentWithAutoGeneratedId() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + // No ID field - should auto-generate + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + verify(mockClient).index(requestCaptor.capture(), any()); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + // ID should be null for auto-generation + assertNull(capturedRequest.id()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testClientExceptionHandling() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + // Mock client to throw exception when index is called + doThrow(new RuntimeException("Simulated client error")).when(mockClient).index(any(IndexRequest.class), any(ActionListener.class)); + + documentService.indexDocument(request, responseObserver); + + // Should call onError due to client exception + verify(responseObserver).onError(any()); + } + + @SuppressWarnings("unchecked") + public void testNullRequestHandling() { + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.indexDocument(null, responseObserver); + + // Should call onError due to null request + verify(responseObserver).onError(any()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testNullResponseObserverHandling() { + IndexDocumentRequest request = IndexDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .setBytesRequestBody(ByteString.copyFromUtf8("{\"field\":\"value\"}")) + .build(); + + // When responseObserver is null, the service should still process the request + // but if any exception occurs, it will fail when trying to call onError(null) + // For a valid request, it should succeed and call the client + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IndexRequest.class); + + documentService.indexDocument(request, null); + + // Should still call client.index + verify(mockClient).index(requestCaptor.capture(), any(ActionListener.class)); + + IndexRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void testUpdateDocumentWithNullRequestBody() { + UpdateDocumentRequest request = UpdateDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + // No request body - this is actually valid, it will just be a no-op update + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UpdateRequest.class); + + documentService.updateDocument(request, responseObserver); + + // Should call client.update with a valid UpdateRequest (even without body) + verify(mockClient).update(requestCaptor.capture(), any(ActionListener.class)); + + UpdateRequest capturedRequest = requestCaptor.getValue(); + assertEquals("test-index", capturedRequest.index()); + assertEquals("test-id", capturedRequest.id()); + } + + @SuppressWarnings("unchecked") + public void testGetDocumentWithSourceFiltering() { + GetDocumentRequest request = GetDocumentRequest.newBuilder() + .setIndex("test-index") + .setId("test-id") + .addXSourceIncludes("user.*") + .addXSourceIncludes("metadata.public.*") + .addXSourceExcludes("user.password") + .addXSourceExcludes("metadata.private.*") + .build(); + + StreamObserver responseObserver = mock(StreamObserver.class); + + documentService.getDocument(request, responseObserver); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(GetRequest.class); + verify(mockClient).get(requestCaptor.capture(), any()); + + GetRequest capturedRequest = requestCaptor.getValue(); + assertNotNull(capturedRequest.fetchSourceContext()); + assertArrayEquals(new String[] { "user.*", "metadata.public.*" }, capturedRequest.fetchSourceContext().includes()); + assertArrayEquals(new String[] { "user.password", "metadata.private.*" }, capturedRequest.fetchSourceContext().excludes()); + } +}