diff --git a/build.gradle b/build.gradle index b4d62bb0b..ab0f0bb6d 100644 --- a/build.gradle +++ b/build.gradle @@ -522,11 +522,7 @@ List jacocoExclusions = [ // TODO: fix unstable code coverage caused by null NodeClient issue // https://github.com/opensearch-project/anomaly-detection/issues/241 'org.opensearch.ad.task.ADBatchTaskRunner', - - // TODO: add tests for multi-category API - 'org.opensearch.ad.transport.SearchTopAnomalyResult*', - 'org.opensearch.ad.rest.RestSearchTopAnomalyResultAction', - 'org.opensearch.ad.model.AnomalyResultBucket', + //TODO: custom result index caused coverage drop 'org.opensearch.ad.indices.AnomalyDetectionIndices', 'org.opensearch.ad.transport.handler.AnomalyResultBulkIndexHandler' diff --git a/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java b/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java index 07450d0c7..ca7e3e126 100644 --- a/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java +++ b/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java @@ -14,6 +14,8 @@ import java.io.IOException; import java.util.Map; +import org.apache.commons.lang.builder.ToStringBuilder; +import org.opensearch.ad.annotation.Generated; import org.opensearch.common.io.stream.StreamInput; import org.opensearch.common.io.stream.StreamOutput; import org.opensearch.common.io.stream.Writeable; @@ -22,6 +24,8 @@ import org.opensearch.search.aggregations.bucket.composite.CompositeAggregation.Bucket; import org.opensearch.search.aggregations.metrics.InternalMax; +import com.google.common.base.Objects; + /** * Represents a single bucket when retrieving top anomaly results for HC detectors */ @@ -72,6 +76,34 @@ public void writeTo(StreamOutput out) throws IOException { out.writeDouble(maxAnomalyGrade); } + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AnomalyResultBucket that = (AnomalyResultBucket) o; + return Objects.equal(getKey(), that.getKey()) + && Objects.equal(getDocCount(), that.getDocCount()) + && Objects.equal(getMaxAnomalyGrade(), that.getMaxAnomalyGrade()); + } + + @Generated + @Override + public int hashCode() { + return Objects.hashCode(getKey(), getDocCount(), getMaxAnomalyGrade()); + } + + @Generated + @Override + public String toString() { + return new ToStringBuilder(this) + .append("key", key) + .append("docCount", docCount) + .append("maxAnomalyGrade", maxAnomalyGrade) + .toString(); + } + public Map getKey() { return key; } diff --git a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java index 94023d308..4f6361608 100644 --- a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java +++ b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java @@ -48,6 +48,7 @@ public class SearchTopAnomalyResultRequest extends ActionRequest { private Instant endTime; public SearchTopAnomalyResultRequest(StreamInput in) throws IOException { + super(in); detectorId = in.readOptionalString(); taskId = in.readOptionalString(); historical = in.readBoolean(); diff --git a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java index 87ddecbed..75b1f2d22 100644 --- a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java +++ b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java @@ -182,12 +182,12 @@ public class SearchTopAnomalyResultTransportAction extends private static final String defaultIndex = ALL_AD_RESULTS_INDEX_PATTERN; private static final String COUNT_FIELD = "_count"; private static final String BUCKET_SORT_FIELD = "bucket_sort"; - private static final String MULTI_BUCKETS_FIELD = "multi_buckets"; + public static final String MULTI_BUCKETS_FIELD = "multi_buckets"; private static final Logger logger = LogManager.getLogger(SearchTopAnomalyResultTransportAction.class); private final Client client; private Clock clock; - private enum OrderType { + public enum OrderType { SEVERITY("severity"), OCCURRENCE("occurrence"); diff --git a/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java b/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java index 474005e0d..b6e85c783 100644 --- a/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java +++ b/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java @@ -17,6 +17,7 @@ import java.io.InputStream; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.Map; import org.apache.http.HttpHeaders; @@ -367,6 +368,25 @@ public Response getSearchDetectorMatch(String name) throws IOException { ); } + public Response searchTopAnomalyResults(String detectorId, boolean historical, String bodyAsJsonString, RestClient client) + throws IOException { + return TestHelpers + .makeRequest( + client, + "POST", + TestHelpers.AD_BASE_DETECTORS_URI + + "/" + + detectorId + + "/" + + RestHandlerUtils.RESULTS + + "/" + + RestHandlerUtils.TOP_ANOMALIES, + Collections.singletonMap("historical", String.valueOf(historical)), + TestHelpers.toHttpEntity(bodyAsJsonString), + new ArrayList<>() + ); + } + public Response createUser(String name, String password, ArrayList backendRoles) throws IOException { JsonArray backendRolesString = new JsonArray(); for (int i = 0; i < backendRoles.size(); i++) { diff --git a/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java b/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java index 2074ffa55..cb4bdc892 100644 --- a/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java +++ b/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java @@ -238,4 +238,18 @@ public ADTask startHistoricalAnalysis(Instant startTime, Instant endTime) throws AnomalyDetectorJobResponse response = client().execute(AnomalyDetectorJobAction.INSTANCE, request).actionGet(10000); return getADTask(response.getId()); } + + public ADTask startHistoricalAnalysis(String detectorId, Instant startTime, Instant endTime) throws IOException { + DetectionDateRange dateRange = new DetectionDateRange(startTime, endTime); + AnomalyDetectorJobRequest request = new AnomalyDetectorJobRequest( + detectorId, + dateRange, + true, + UNASSIGNED_SEQ_NO, + UNASSIGNED_PRIMARY_TERM, + START_JOB + ); + AnomalyDetectorJobResponse response = client().execute(AnomalyDetectorJobAction.INSTANCE, request).actionGet(10000); + return getADTask(response.getId()); + } } diff --git a/src/test/java/org/opensearch/ad/TestHelpers.java b/src/test/java/org/opensearch/ad/TestHelpers.java index abe97011a..7e833e5e2 100644 --- a/src/test/java/org/opensearch/ad/TestHelpers.java +++ b/src/test/java/org/opensearch/ad/TestHelpers.java @@ -16,14 +16,7 @@ import static org.opensearch.common.xcontent.XContentParserUtils.ensureExpectedToken; import static org.opensearch.index.query.AbstractQueryBuilder.parseInnerQueryBuilder; import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; -import static org.opensearch.test.OpenSearchTestCase.buildNewFakeTransportAddress; -import static org.opensearch.test.OpenSearchTestCase.randomAlphaOfLength; -import static org.opensearch.test.OpenSearchTestCase.randomBoolean; -import static org.opensearch.test.OpenSearchTestCase.randomDouble; -import static org.opensearch.test.OpenSearchTestCase.randomDoubleBetween; -import static org.opensearch.test.OpenSearchTestCase.randomInt; -import static org.opensearch.test.OpenSearchTestCase.randomIntBetween; -import static org.opensearch.test.OpenSearchTestCase.randomLong; +import static org.opensearch.test.OpenSearchTestCase.*; import static org.powermock.api.mockito.PowerMockito.mock; import static org.powermock.api.mockito.PowerMockito.when; @@ -66,6 +59,7 @@ import org.opensearch.ad.constant.CommonName; import org.opensearch.ad.constant.CommonValue; import org.opensearch.ad.feature.Features; +import org.opensearch.ad.indices.AnomalyDetectionIndices; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.mock.model.MockSimpleLog; import org.opensearch.ad.model.ADTask; @@ -75,6 +69,7 @@ import org.opensearch.ad.model.AnomalyDetectorExecutionInput; import org.opensearch.ad.model.AnomalyDetectorJob; import org.opensearch.ad.model.AnomalyResult; +import org.opensearch.ad.model.AnomalyResultBucket; import org.opensearch.ad.model.DataByFeatureId; import org.opensearch.ad.model.DetectionDateRange; import org.opensearch.ad.model.DetectorInternalState; @@ -371,13 +366,37 @@ public static DetectionDateRange randomDetectionDateRange() { public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields(String detectorId, List categoryFields) throws IOException { + return randomAnomalyDetectorUsingCategoryFields( + detectorId, + randomAlphaOfLength(5), + ImmutableList.of(randomAlphaOfLength(10).toLowerCase()), + categoryFields + ); + } + + public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( + String detectorId, + String timeField, + List indices, + List categoryFields + ) throws IOException { + return randomAnomalyDetectorUsingCategoryFields(detectorId, timeField, indices, categoryFields, null); + } + + public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( + String detectorId, + String timeField, + List indices, + List categoryFields, + String resultIndex + ) throws IOException { return new AnomalyDetector( detectorId, randomLong(), randomAlphaOfLength(20), randomAlphaOfLength(30), - randomAlphaOfLength(5), - ImmutableList.of(randomAlphaOfLength(10).toLowerCase()), + timeField, + indices, ImmutableList.of(randomFeature(true)), randomQuery(), randomIntervalTimeConfiguration(), @@ -388,7 +407,7 @@ public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields(String de Instant.now(), categoryFields, randomUser(), - null + resultIndex ); } @@ -463,6 +482,12 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio ); } + public static AnomalyResultBucket randomAnomalyResultBucket() { + Map map = new HashMap<>(); + map.put(randomAlphaOfLength(5), randomAlphaOfLength(5)); + return new AnomalyResultBucket(map, randomInt(), randomDouble()); + } + public static class AnomalyDetectorBuilder { private String detectorId = randomAlphaOfLength(10); private Long version = randomLong(); @@ -842,6 +867,31 @@ public static ResultWriteRequest randomResultWriteRequest(String detectorId, dou } public static AnomalyResult randomHCADAnomalyDetectResult(double score, double grade, String error) { + return randomHCADAnomalyDetectResult(null, null, score, grade, error, null, null); + } + + public static AnomalyResult randomHCADAnomalyDetectResult( + String detectorId, + String taskId, + double score, + double grade, + String error, + Long startTimeEpochMillis, + Long endTimeEpochMillis + ) { + return randomHCADAnomalyDetectResult(detectorId, taskId, null, score, grade, error, startTimeEpochMillis, endTimeEpochMillis); + } + + public static AnomalyResult randomHCADAnomalyDetectResult( + String detectorId, + String taskId, + Map entityAttrs, + double score, + double grade, + String error, + Long startTimeEpochMillis, + Long endTimeEpochMillis + ) { List relavantAttribution = new ArrayList(); relavantAttribution.add(new DataByFeatureId(randomAlphaOfLength(5), randomDoubleBetween(0, 1.0, true))); relavantAttribution.add(new DataByFeatureId(randomAlphaOfLength(5), randomDoubleBetween(0, 1.0, true))); @@ -857,18 +907,20 @@ public static AnomalyResult randomHCADAnomalyDetectResult(double score, double g expectedValuesList.add(new ExpectedValueList(randomDoubleBetween(0, 1.0, true), expectedValues)); return new AnomalyResult( - randomAlphaOfLength(5), - null, + detectorId == null ? randomAlphaOfLength(5) : detectorId, + taskId, score, grade, randomDouble(), ImmutableList.of(randomFeatureData(), randomFeatureData()), - Instant.now().truncatedTo(ChronoUnit.SECONDS), - Instant.now().truncatedTo(ChronoUnit.SECONDS), - Instant.now().truncatedTo(ChronoUnit.SECONDS), - Instant.now().truncatedTo(ChronoUnit.SECONDS), + startTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(startTimeEpochMillis), + endTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(endTimeEpochMillis), + startTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(startTimeEpochMillis), + endTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(endTimeEpochMillis), error, - Entity.createSingleAttributeEntity(randomAlphaOfLength(5), randomAlphaOfLength(5)), + entityAttrs == null + ? Entity.createSingleAttributeEntity(randomAlphaOfLength(5), randomAlphaOfLength(5)) + : Entity.createEntityByReordering(entityAttrs), randomUser(), CommonValue.NO_SCHEMA_VERSION, null, @@ -1001,6 +1053,44 @@ public static void createIndex(RestClient client, String indexName, HttpEntity d ); } + public static void createIndexWithHCADFields(RestClient client, String indexName, Map categoryFieldsAndTypes) + throws IOException { + StringBuilder indexMappings = new StringBuilder(); + indexMappings.append("{\"properties\":{"); + for (Map.Entry entry : categoryFieldsAndTypes.entrySet()) { + indexMappings.append("\"" + entry.getKey() + "\":{\"type\":\"" + entry.getValue() + "\"},"); + } + indexMappings.append("\"timestamp\":{\"type\":\"date\"}"); + indexMappings.append("}}"); + createEmptyIndex(client, indexName); + createIndexMapping(client, indexName, TestHelpers.toHttpEntity(indexMappings.toString())); + } + + public static void createEmptyAnomalyResultIndex(RestClient client) throws IOException { + createEmptyIndex(client, CommonName.ANOMALY_RESULT_INDEX_ALIAS); + createIndexMapping(client, CommonName.ANOMALY_RESULT_INDEX_ALIAS, toHttpEntity(AnomalyDetectionIndices.getAnomalyResultMappings())); + } + + public static void createEmptyIndex(RestClient client, String indexName) throws IOException { + TestHelpers.makeRequest(client, "PUT", "/" + indexName, ImmutableMap.of(), "", null); + } + + public static void createIndexMapping(RestClient client, String indexName, HttpEntity mappings) throws IOException { + TestHelpers.makeRequest(client, "POST", "/" + indexName + "/_mapping", ImmutableMap.of(), mappings, null); + } + + public static void ingestDataToIndex(RestClient client, String indexName, HttpEntity data) throws IOException { + TestHelpers + .makeRequest( + client, + "POST", + "/" + indexName + "/_doc/" + randomAlphaOfLength(5) + "?refresh=true", + ImmutableMap.of(), + data, + null + ); + } + public static GetResponse createGetResponse(ToXContentObject o, String id, String indexName) throws IOException { XContentBuilder content = o.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS); return new GetResponse( diff --git a/src/test/java/org/opensearch/ad/model/AnomalyResultBucketTests.java b/src/test/java/org/opensearch/ad/model/AnomalyResultBucketTests.java new file mode 100644 index 000000000..55e2ce28d --- /dev/null +++ b/src/test/java/org/opensearch/ad/model/AnomalyResultBucketTests.java @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +import static org.opensearch.ad.model.AnomalyResultBucket.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.opensearch.ad.AbstractADTest; +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.StreamInput; +import org.opensearch.common.xcontent.ToXContent; +import org.opensearch.common.xcontent.XContentBuilder; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentParser; + +public class AnomalyResultBucketTests extends AbstractADTest { + + public void testSerializeAnomalyResultBucket() throws IOException { + AnomalyResultBucket anomalyResultBucket = TestHelpers.randomAnomalyResultBucket(); + BytesStreamOutput output = new BytesStreamOutput(); + anomalyResultBucket.writeTo(output); + StreamInput input = output.bytes().streamInput(); + AnomalyResultBucket parsedAnomalyResultBucket = new AnomalyResultBucket(input); + assertTrue(parsedAnomalyResultBucket.equals(anomalyResultBucket)); + } + + @SuppressWarnings("unchecked") + public void testToXContent() throws IOException { + Map key = new HashMap() { + { + put("test-field-1", "test-value-1"); + } + }; + int docCount = 5; + double maxAnomalyGrade = 0.5; + AnomalyResultBucket testBucket = new AnomalyResultBucket(key, docCount, maxAnomalyGrade); + XContentBuilder builder = XContentFactory.jsonBuilder(); + testBucket.toXContent(builder, ToXContent.EMPTY_PARAMS); + XContentParser parser = createParser(builder); + Map parsedMap = parser.map(); + + assertEquals(testBucket.getKey().get("test-field-1"), ((Map) parsedMap.get(KEY_FIELD)).get("test-field-1")); + assertEquals(testBucket.getDocCount(), parsedMap.get(DOC_COUNT_FIELD)); + assertEquals(maxAnomalyGrade, (Double) parsedMap.get(MAX_ANOMALY_GRADE_FIELD), 0.000001d); + } +} diff --git a/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java b/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java index dbfbede8f..29c8bdddb 100644 --- a/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java +++ b/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java @@ -19,10 +19,13 @@ import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import org.apache.http.entity.ContentType; import org.apache.http.nio.entity.NStringEntity; @@ -31,6 +34,7 @@ import org.opensearch.ad.AnomalyDetectorRestTestCase; import org.opensearch.ad.TestHelpers; import org.opensearch.ad.constant.CommonErrorMessages; +import org.opensearch.ad.constant.CommonName; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.model.AnomalyDetectorExecutionInput; import org.opensearch.ad.model.AnomalyDetectorJob; @@ -1504,4 +1508,393 @@ public void testValidateAnomalyDetectorWithWrongCategoryField() throws Exception ); } + + public void testSearchTopAnomalyResultsWithInvalidInputs() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"field-1\", \"ip-field\": \"1.2.3.4\", \"timestamp\": 1}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Missing start time + Exception missingStartTimeException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), false, "{\"end_time_ms\":2}", client()); } + ); + assertTrue(missingStartTimeException.getMessage().contains("Must set both start time and end time with epoch of milliseconds")); + + // Missing end time + Exception missingEndTimeException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":1}", client()); } + ); + assertTrue(missingEndTimeException.getMessage().contains("Must set both start time and end time with epoch of milliseconds")); + + // Start time > end time + Exception invalidTimeException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":2, \"end_time_ms\":1}", client()); } + ); + assertTrue(invalidTimeException.getMessage().contains("Start time should be before end time")); + + // Invalid detector ID + Exception invalidDetectorIdException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults(detector.getDetectorId() + "-invalid", false, "{\"start_time_ms\":1, \"end_time_ms\":2}", client()); + } + ); + assertTrue(invalidDetectorIdException.getMessage().contains("Can't find detector with id")); + + // Invalid order field + Exception invalidOrderException = expectThrows(IOException.class, () -> { + searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2, \"order\":\"invalid-order\"}", + client() + ); + }); + assertTrue(invalidOrderException.getMessage().contains("Ordering by invalid-order is not a valid option")); + + // Negative size field + Exception negativeSizeException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":1, \"end_time_ms\":2, \"size\":-1}", client()); + } + ); + assertTrue(negativeSizeException.getMessage().contains("Size must be a positive integer")); + + // Zero size field + Exception zeroSizeException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":1, \"end_time_ms\":2, \"size\":0}", client()); + } + ); + assertTrue(zeroSizeException.getMessage().contains("Size must be a positive integer")); + + // Too large size field + Exception tooLargeSizeException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2, \"size\":9999999}", + client() + ); + } + ); + assertTrue(tooLargeSizeException.getMessage().contains("Size cannot exceed")); + + // No existing task ID for detector + Exception noTaskIdException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), true, "{\"start_time_ms\":1, \"end_time_ms\":2}", client()); } + ); + assertTrue(noTaskIdException.getMessage().contains("No historical tasks found for detector ID " + detector.getDetectorId())); + + // Invalid category fields + Exception invalidCategoryFieldsException = expectThrows(IOException.class, () -> { + searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2, \"category_field\":[\"invalid-field\"]}", + client() + ); + }); + assertTrue( + invalidCategoryFieldsException + .getMessage() + .contains("Category field invalid-field doesn't exist for detector ID " + detector.getDetectorId()) + ); + + // Using detector with no category fields + AnomalyDetector detectorWithNoCategoryFields = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + ImmutableList.of() + ), + true, + client() + ); + Exception noCategoryFieldsException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults( + detectorWithNoCategoryFields.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2}", + client() + ); + } + ); + assertTrue( + noCategoryFieldsException + .getMessage() + .contains("No category fields found for detector ID " + detectorWithNoCategoryFields.getDetectorId()) + ); + } + + public void testSearchTopAnomalyResultsOnNonExistentResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"test-value\"}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Delete any existing result index + if (indexExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS)) { + deleteIndex(CommonName.ANOMALY_RESULT_INDEX_ALIAS); + } + Response response = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"size\":3,\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":1}", + client() + ); + Map responseMap = entityAsMap(response); + @SuppressWarnings("unchecked") + List> buckets = (ArrayList>) XContentMapValues.extractValue("buckets", responseMap); + assertEquals(0, buckets.size()); + } + + public void testSearchTopAnomalyResultsOnEmptyResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"test-value\"}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Clear any existing result index, create an empty one + if (indexExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS)) { + deleteIndex(CommonName.ANOMALY_RESULT_INDEX_ALIAS); + } + TestHelpers.createEmptyAnomalyResultIndex(client()); + Response response = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"size\":3,\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":1}", + client() + ); + Map responseMap = entityAsMap(response); + @SuppressWarnings("unchecked") + List> buckets = (ArrayList>) XContentMapValues.extractValue("buckets", responseMap); + assertEquals(0, buckets.size()); + } + + public void testSearchTopAnomalyResultsOnPopulatedResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"field-1\", \"ip-field\": \"1.2.3.4\", \"timestamp\": 1}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Ingest some sample results + if (!indexExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS)) { + TestHelpers.createEmptyAnomalyResultIndex(client()); + } + Map entityAttrs1 = new HashMap() { + { + put("keyword-field", "field-1"); + put("ip-field", "1.2.3.4"); + } + }; + Map entityAttrs2 = new HashMap() { + { + put("keyword-field", "field-2"); + put("ip-field", "5.6.7.8"); + } + }; + Map entityAttrs3 = new HashMap() { + { + put("keyword-field", "field-2"); + put("ip-field", "5.6.7.8"); + } + }; + AnomalyResult anomalyResult1 = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs1, 0.5, 0.8, null, 5L, 5L); + AnomalyResult anomalyResult2 = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs2, 0.5, 0.5, null, 5L, 5L); + AnomalyResult anomalyResult3 = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs3, 0.5, 0.2, null, 5L, 5L); + + TestHelpers.ingestDataToIndex(client(), CommonName.ANOMALY_RESULT_INDEX_ALIAS, TestHelpers.toHttpEntity(anomalyResult1)); + TestHelpers.ingestDataToIndex(client(), CommonName.ANOMALY_RESULT_INDEX_ALIAS, TestHelpers.toHttpEntity(anomalyResult2)); + TestHelpers.ingestDataToIndex(client(), CommonName.ANOMALY_RESULT_INDEX_ALIAS, TestHelpers.toHttpEntity(anomalyResult3)); + + // Sorting by severity + Response severityResponse = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":10, \"order\":\"severity\"}", + client() + ); + Map severityResponseMap = entityAsMap(severityResponse); + @SuppressWarnings("unchecked") + List> severityBuckets = (ArrayList>) XContentMapValues + .extractValue("buckets", severityResponseMap); + assertEquals(2, severityBuckets.size()); + @SuppressWarnings("unchecked") + Map severityBucketKey1 = (Map) severityBuckets.get(0).get("key"); + @SuppressWarnings("unchecked") + Map severityBucketKey2 = (Map) severityBuckets.get(1).get("key"); + assertEquals("field-1", severityBucketKey1.get("keyword-field")); + assertEquals("field-2", severityBucketKey2.get("keyword-field")); + + // Sorting by occurrence + Response occurrenceResponse = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":10, \"order\":\"occurrence\"}", + client() + ); + Map occurrenceResponseMap = entityAsMap(occurrenceResponse); + @SuppressWarnings("unchecked") + List> occurrenceBuckets = (ArrayList>) XContentMapValues + .extractValue("buckets", occurrenceResponseMap); + assertEquals(2, occurrenceBuckets.size()); + @SuppressWarnings("unchecked") + Map occurrenceBucketKey1 = (Map) occurrenceBuckets.get(0).get("key"); + @SuppressWarnings("unchecked") + Map occurrenceBucketKey2 = (Map) occurrenceBuckets.get(1).get("key"); + assertEquals("field-2", occurrenceBucketKey1.get("keyword-field")); + assertEquals("field-1", occurrenceBucketKey2.get("keyword-field")); + + // Sorting using all category fields + Response allFieldsResponse = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"category_field\":[\"keyword-field\", \"ip-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":10, \"order\":\"severity\"}", + client() + ); + Map allFieldsResponseMap = entityAsMap(allFieldsResponse); + @SuppressWarnings("unchecked") + List> allFieldsBuckets = (ArrayList>) XContentMapValues + .extractValue("buckets", allFieldsResponseMap); + assertEquals(2, allFieldsBuckets.size()); + @SuppressWarnings("unchecked") + Map allFieldsBucketKey1 = (Map) allFieldsBuckets.get(0).get("key"); + @SuppressWarnings("unchecked") + Map allFieldsBucketKey2 = (Map) allFieldsBuckets.get(1).get("key"); + assertEquals("field-1", allFieldsBucketKey1.get("keyword-field")); + assertEquals("1.2.3.4", allFieldsBucketKey1.get("ip-field")); + assertEquals("field-2", allFieldsBucketKey2.get("keyword-field")); + assertEquals("5.6.7.8", allFieldsBucketKey2.get("ip-field")); + } + + public void testSearchTopAnomalyResultsWithCustomResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + String customResultIndexName = CommonName.CUSTOM_RESULT_INDEX_PREFIX + randomAlphaOfLength(5).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"field-1\", \"ip-field\": \"1.2.3.4\", \"timestamp\": 1}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()), + customResultIndexName + ), + true, + client() + ); + + Map entityAttrs = new HashMap() { + { + put("keyword-field", "field-1"); + put("ip-field", "1.2.3.4"); + } + }; + AnomalyResult anomalyResult = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs, 0.5, 0.8, null, 5L, 5L); + TestHelpers.ingestDataToIndex(client(), customResultIndexName, TestHelpers.toHttpEntity(anomalyResult)); + + Response response = searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":0, \"end_time_ms\":10}", client()); + Map responseMap = entityAsMap(response); + @SuppressWarnings("unchecked") + List> buckets = (ArrayList>) XContentMapValues.extractValue("buckets", responseMap); + assertEquals(1, buckets.size()); + @SuppressWarnings("unchecked") + Map bucketKey1 = (Map) buckets.get(0).get("key"); + assertEquals("field-1", bucketKey1.get("keyword-field")); + assertEquals("1.2.3.4", bucketKey1.get("ip-field")); + } } diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultActionTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultActionTests.java new file mode 100644 index 000000000..7669b8c5b --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultActionTests.java @@ -0,0 +1,268 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import static org.opensearch.ad.settings.AnomalyDetectorSettings.BATCH_TASK_PIECE_INTERVAL_SECONDS; +import static org.opensearch.ad.settings.AnomalyDetectorSettings.MAX_BATCH_TASK_PER_NODE; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +import org.junit.Before; +import org.opensearch.ad.HistoricalAnalysisIntegTestCase; +import org.opensearch.ad.TestHelpers; +import org.opensearch.ad.model.AnomalyDetector; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchIntegTestCase; + +import com.google.common.collect.ImmutableList; + +// Only invalid test cases are covered here. This is due to issues with the lang-painless module not +// being installed on test clusters spun up in OpenSearchIntegTestCase classes (which this class extends), +// which is needed for executing the API on ingested data. Valid test cases are covered at the REST layer. +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST) +public class SearchTopAnomalyResultActionTests extends HistoricalAnalysisIntegTestCase { + + private String testIndex; + private String detectorId; + private String taskId; + private Instant startTime; + private Instant endTime; + private ImmutableList categoryFields; + private String type = "error"; + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings + .builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(BATCH_TASK_PIECE_INTERVAL_SECONDS.getKey(), 1) + .put(MAX_BATCH_TASK_PER_NODE.getKey(), 1) + .build(); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + testIndex = "test_data"; + taskId = "test-task-id"; + startTime = Instant.now().minus(10, ChronoUnit.DAYS); + endTime = Instant.now(); + categoryFields = ImmutableList.of("test-field-1", "test-field-2"); + ingestTestData(); + createSystemIndices(); + createAndIndexDetector(); + } + + private void ingestTestData() { + ingestTestData(testIndex, startTime, 1, "test", 1); + } + + private void createSystemIndices() throws IOException { + createDetectorIndex(); + createADResultIndex(); + } + + private void createAndIndexDetector() throws IOException { + AnomalyDetector detector = TestHelpers + .randomAnomalyDetector( + ImmutableList.of(testIndex), + ImmutableList.of(TestHelpers.randomFeature(true)), + null, + Instant.now(), + 1, + false, + categoryFields + ); + detectorId = createDetector(detector); + + } + + public void testInstanceAndNameValid() { + assertNotNull(SearchTopAnomalyResultAction.INSTANCE.name()); + assertEquals(SearchTopAnomalyResultAction.INSTANCE.name(), SearchTopAnomalyResultAction.NAME); + } + + public void testInvalidOrder() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + "invalid-order", + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testNegativeSize() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + -1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testZeroSize() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 0, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testTooLargeSize() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 9999999, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testMissingStartTime() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + null, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testMissingEndTime() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + null + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testInvalidStartAndEndTimes() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + endTime, + startTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + + Instant curTimeInMillis = Instant.now(); + SearchTopAnomalyResultRequest searchRequest2 = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + curTimeInMillis, + curTimeInMillis + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest2).actionGet(10_000) + ); + } + + public void testNoExistingHistoricalTask() throws IOException { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + true, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows(Exception.class, () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000)); + } + + public void testSearchOnNonHCDetector() throws IOException { + AnomalyDetector nonHCDetector = TestHelpers + .randomAnomalyDetector( + ImmutableList.of(testIndex), + ImmutableList.of(TestHelpers.randomFeature(true)), + null, + Instant.now(), + 1, + false, + ImmutableList.of() + ); + String nonHCDetectorId = createDetector(nonHCDetector); + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + nonHCDetectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequestTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequestTests.java new file mode 100644 index 000000000..c21f444e7 --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequestTests.java @@ -0,0 +1,189 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Assert; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.StreamInput; +import org.opensearch.common.xcontent.XContentBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class SearchTopAnomalyResultRequestTests extends OpenSearchTestCase { + + public void testSerialization() throws IOException { + SearchTopAnomalyResultRequest originalRequest = new SearchTopAnomalyResultRequest( + "test-detector-id", + "test-task-id", + false, + 1, + Arrays.asList("test-field"), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + + BytesStreamOutput output = new BytesStreamOutput(); + originalRequest.writeTo(output); + StreamInput input = output.bytes().streamInput(); + SearchTopAnomalyResultRequest parsedRequest = new SearchTopAnomalyResultRequest(input); + assertEquals(originalRequest.getDetectorId(), parsedRequest.getDetectorId()); + assertEquals(originalRequest.getTaskId(), parsedRequest.getTaskId()); + assertEquals(originalRequest.getHistorical(), parsedRequest.getHistorical()); + assertEquals(originalRequest.getSize(), parsedRequest.getSize()); + assertEquals(originalRequest.getCategoryFields(), parsedRequest.getCategoryFields()); + assertEquals(originalRequest.getOrder(), parsedRequest.getOrder()); + assertEquals(originalRequest.getStartTime(), parsedRequest.getStartTime()); + assertEquals(originalRequest.getEndTime(), parsedRequest.getEndTime()); + } + + public void testParse() throws IOException { + String detectorId = "test-detector-id"; + boolean historical = false; + String taskId = "test-task-id"; + int size = 5; + List categoryFields = Arrays.asList("field-1", "field-2"); + String order = "severity"; + Instant startTime = Instant.ofEpochMilli(1234); + Instant endTime = Instant.ofEpochMilli(5678); + + XContentBuilder xContentBuilder = TestHelpers + .builder() + .startObject() + .field("task_id", taskId) + .field("size", size) + .field("category_field", categoryFields) + .field("order", order) + .field("start_time_ms", startTime.toEpochMilli()) + .field("end_time_ms", endTime.toEpochMilli()) + .endObject(); + + String requestAsXContentString = TestHelpers.xContentBuilderToString(xContentBuilder); + SearchTopAnomalyResultRequest parsedRequest = SearchTopAnomalyResultRequest + .parse(TestHelpers.parser(requestAsXContentString), "test-detector-id", false); + assertEquals(taskId, parsedRequest.getTaskId()); + assertEquals((Integer) size, parsedRequest.getSize()); + assertEquals(categoryFields, parsedRequest.getCategoryFields()); + assertEquals(order, parsedRequest.getOrder()); + assertEquals(startTime.toEpochMilli(), parsedRequest.getStartTime().toEpochMilli()); + assertEquals(endTime.toEpochMilli(), parsedRequest.getEndTime().toEpochMilli()); + assertEquals(detectorId, parsedRequest.getDetectorId()); + assertEquals(historical, parsedRequest.getHistorical()); + } + + public void testNullTaskIdIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + null, + false, + 1, + Arrays.asList("test-field"), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testNullSizeIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + null, + Arrays.asList("test-field"), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testNullCategoryFieldIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + null, + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testEmptyCategoryFieldIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testEmptyStartTimeIsInvalid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + null, + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNotNull(exception); + } + + public void testEmptyEndTimeIsInvalid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + null + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNotNull(exception); + } + + public void testEndTimeBeforeStartTimeIsInvalid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(2, ChronoUnit.DAYS), + Instant.now().minus(10, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNotNull(exception); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultResponseTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultResponseTests.java new file mode 100644 index 000000000..841368c36 --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultResponseTests.java @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; + +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.StreamInput; +import org.opensearch.test.OpenSearchTestCase; + +public class SearchTopAnomalyResultResponseTests extends OpenSearchTestCase { + + public void testSerialization() throws IOException { + SearchTopAnomalyResultResponse originalResponse = new SearchTopAnomalyResultResponse( + Arrays.asList(TestHelpers.randomAnomalyResultBucket()) + ); + + BytesStreamOutput output = new BytesStreamOutput(); + originalResponse.writeTo(output); + StreamInput input = output.bytes().streamInput(); + SearchTopAnomalyResultResponse parsedResponse = new SearchTopAnomalyResultResponse(input); + assertEquals(originalResponse.getAnomalyResultBuckets(), parsedResponse.getAnomalyResultBuckets()); + } + + public void testEmptyResults() { + SearchTopAnomalyResultResponse response = new SearchTopAnomalyResultResponse(new ArrayList<>()); + } + + public void testPopulatedResults() { + SearchTopAnomalyResultResponse response = new SearchTopAnomalyResultResponse( + Arrays.asList(TestHelpers.randomAnomalyResultBucket()) + ); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportActionTests.java new file mode 100644 index 000000000..7d86fd3d1 --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportActionTests.java @@ -0,0 +1,360 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; +import org.opensearch.action.ActionListener; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchResponseSections; +import org.opensearch.action.search.ShardSearchFailure; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.ad.ADIntegTestCase; +import org.opensearch.ad.TestHelpers; +import org.opensearch.ad.constant.CommonName; +import org.opensearch.ad.model.AnomalyResultBucket; +import org.opensearch.ad.transport.handler.ADSearchHandler; +import org.opensearch.client.Client; +import org.opensearch.search.SearchHits; +import org.opensearch.search.aggregations.Aggregation; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.composite.CompositeAggregation; +import org.opensearch.search.aggregations.metrics.InternalMax; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.transport.TransportService; + +import com.google.common.collect.ImmutableList; + +public class SearchTopAnomalyResultTransportActionTests extends ADIntegTestCase { + private SearchTopAnomalyResultTransportAction action; + + // Helper method to generate the Aggregations obj using the list of result buckets + private Aggregations generateAggregationsFromBuckets(List buckets, Map mockAfterKeyValue) { + List bucketList = new ArrayList<>(); + + for (AnomalyResultBucket bucket : buckets) { + InternalMax maxGradeAgg = mock(InternalMax.class); + when(maxGradeAgg.getName()).thenReturn(AnomalyResultBucket.MAX_ANOMALY_GRADE_FIELD); + when(maxGradeAgg.getValue()).thenReturn(bucket.getMaxAnomalyGrade()); + CompositeAggregation.Bucket aggBucket = mock(CompositeAggregation.Bucket.class); + when(aggBucket.getKey()).thenReturn(bucket.getKey()); + when(aggBucket.getDocCount()).thenReturn((long) bucket.getDocCount()); + when(aggBucket.getAggregations()).thenReturn(new Aggregations(new ArrayList() { + { + add(maxGradeAgg); + } + })); + bucketList.add(aggBucket); + } + + CompositeAggregation composite = mock(CompositeAggregation.class); + when(composite.getName()).thenReturn(SearchTopAnomalyResultTransportAction.MULTI_BUCKETS_FIELD); + when(composite.getBuckets()).thenAnswer((Answer>) invocation -> bucketList); + when(composite.afterKey()).thenReturn(mockAfterKeyValue); + + List aggList = Collections.singletonList(composite); + return new Aggregations(aggList); + } + + // Helper method to generate a SearchResponse obj using the given aggs + private SearchResponse generateMockSearchResponse(Aggregations aggs) { + SearchResponseSections sections = new SearchResponseSections(SearchHits.empty(), aggs, null, false, null, null, 1); + return new SearchResponse(sections, null, 1, 1, 0, 0, ShardSearchFailure.EMPTY_ARRAY, SearchResponse.Clusters.EMPTY); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + action = new SearchTopAnomalyResultTransportAction( + mock(TransportService.class), + mock(ActionFilters.class), + mock(ADSearchHandler.class), + mock(Client.class) + ); + } + + public void testSearchOnNonExistingResultIndex() throws IOException { + deleteIndexIfExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS); + String testIndexName = randomAlphaOfLength(10).toLowerCase(); + ImmutableList categoryFields = ImmutableList.of("test-field-1", "test-field-2"); + String detectorId = createDetector( + TestHelpers + .randomAnomalyDetector( + ImmutableList.of(testIndexName), + ImmutableList.of(TestHelpers.randomFeature(true)), + null, + Instant.now(), + 1, + false, + categoryFields + ) + ); + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + null, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now() + ); + SearchTopAnomalyResultResponse searchResponse = client() + .execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest) + .actionGet(10_000); + assertEquals(searchResponse.getAnomalyResultBuckets().size(), 0); + } + + @SuppressWarnings("unchecked") + public void testListenerWithNullResult() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Exception.class); + + listener.onResponse(null); + + verify(mockListener, times(1)).onFailure(failureCaptor.capture()); + assertTrue(failureCaptor.getValue() != null); + } + + @SuppressWarnings("unchecked") + public void testListenerWithNullAggregation() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + SearchResponse response = generateMockSearchResponse(null); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(0, capturedResponse.getAnomalyResultBuckets().size()); + } + + @SuppressWarnings("unchecked") + public void testListenerWithInvalidAggregation() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + // an empty list won't have an entry for 'MULTI_BUCKETS_FIELD' as needed to parse out + // the expected result buckets, and thus should fail + Aggregations aggs = new Aggregations(new ArrayList<>()); + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Exception.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onFailure(failureCaptor.capture()); + assertTrue(failureCaptor.getValue() != null); + } + + @SuppressWarnings("unchecked") + public void testListenerWithValidEmptyAggregation() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + CompositeAggregation composite = mock(CompositeAggregation.class); + when(composite.getName()).thenReturn(SearchTopAnomalyResultTransportAction.MULTI_BUCKETS_FIELD); + when(composite.getBuckets()).thenReturn(new ArrayList<>()); + when(composite.afterKey()).thenReturn(null); + List aggList = Collections.singletonList(composite); + Aggregations aggs = new Aggregations(aggList); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(0, capturedResponse.getAnomalyResultBuckets().size()); + } + + @SuppressWarnings("unchecked") + public void testListenerTimesOutWithNoResults() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, // this is guaranteed to be an expired timestamp + 10, SearchTopAnomalyResultTransportAction.OrderType.OCCURRENCE, "custom-result-index-name" + ); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList<>(), new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }); + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Exception.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onFailure(failureCaptor.capture()); + assertTrue(failureCaptor.getValue() != null); + } + + @SuppressWarnings("unchecked") + public void testListenerTimesOutWithPartialResults() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, // this is guaranteed to be an expired timestamp + 10, SearchTopAnomalyResultTransportAction.OrderType.OCCURRENCE, "custom-result-index-name" + ); + + AnomalyResultBucket expectedResponseBucket1 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-1"); + } + }, 5, 0.2); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList() { + { + add(expectedResponseBucket1); + } + }, new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(1, capturedResponse.getAnomalyResultBuckets().size()); + assertEquals(expectedResponseBucket1, capturedResponse.getAnomalyResultBuckets().get(0)); + } + + @SuppressWarnings("unchecked") + public void testListenerSortingBySeverity() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + AnomalyResultBucket expectedResponseBucket1 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-1"); + } + }, 5, 0.2); + AnomalyResultBucket expectedResponseBucket2 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }, 5, 0.3); + AnomalyResultBucket expectedResponseBucket3 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-3"); + } + }, 5, 0.1); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList() { + { + add(expectedResponseBucket1); + add(expectedResponseBucket2); + add(expectedResponseBucket3); + } + }, null); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(3, capturedResponse.getAnomalyResultBuckets().size()); + assertEquals(expectedResponseBucket2, capturedResponse.getAnomalyResultBuckets().get(0)); + assertEquals(expectedResponseBucket1, capturedResponse.getAnomalyResultBuckets().get(1)); + assertEquals(expectedResponseBucket3, capturedResponse.getAnomalyResultBuckets().get(2)); + } + + @SuppressWarnings("unchecked") + public void testListenerSortingByOccurrence() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.OCCURRENCE, + "custom-result-index-name" + ); + + AnomalyResultBucket expectedResponseBucket1 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-1"); + } + }, 2, 0.5); + AnomalyResultBucket expectedResponseBucket2 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }, 3, 0.5); + AnomalyResultBucket expectedResponseBucket3 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-3"); + } + }, 1, 0.5); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList() { + { + add(expectedResponseBucket1); + add(expectedResponseBucket2); + add(expectedResponseBucket3); + } + }, null); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(3, capturedResponse.getAnomalyResultBuckets().size()); + assertEquals(expectedResponseBucket2, capturedResponse.getAnomalyResultBuckets().get(0)); + assertEquals(expectedResponseBucket1, capturedResponse.getAnomalyResultBuckets().get(1)); + assertEquals(expectedResponseBucket3, capturedResponse.getAnomalyResultBuckets().get(2)); + } +}