Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.junit.jupiter.api.TestInstance;
import org.openmetadata.it.server.SearchTestImages;
import org.openmetadata.schema.type.IndexMappingLanguage;
import org.openmetadata.service.search.indexes.TestCaseResolutionStatusIndex;
import org.openmetadata.service.search.opensearch.OsUtils;
import org.opensearch.testcontainers.OpensearchContainer;
import org.testcontainers.junit.jupiter.Container;
Expand Down Expand Up @@ -58,6 +59,10 @@
* entityLink.nonNormalized} per-entity summary aggregation, and the dimension/platform filters — and
* the Incident Manager resolution-status index — status type, assignee, and the {@code
* testCase.fullyQualifiedName.keyword}/{@code testCase.entityFQN.keyword} filters.
*
* <p>Both branches of the {@code testCaseResolutionStatusDetails} oneOf are indexed as separate
* documents (Assigned and Resolved), because a mapping that only ever sees one branch can declare a
* shape the other branch can never produce and nothing notices.
*/
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
Expand Down Expand Up @@ -93,6 +98,15 @@ class SearchConsumerFieldBehaviorIT {
private static final String ASSIGNEE_ID = "77777777-7777-7777-7777-777777777777";
private static final String ASSIGNEE_NAME = "john";
private static final String STATE_ID = "88888888-8888-8888-8888-888888888888";
private static final String RESOLVED_ID = "99999999-9999-9999-9999-999999999999";
private static final String RESOLVED_STATUS_TYPE = "Resolved";
private static final String RESOLVED_STATE_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private static final String RESOLVER_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
private static final String RESOLVER_NAME = "carol";
private static final String FAILURE_REASON = "FalsePositive";
private static final String FAILURE_COMMENT_TERM = "flakyupstream";
private static final String FAILURE_COMMENT =
"closed after the " + FAILURE_COMMENT_TERM + " feed was backfilled";

// Native-script sample text per language plus a token its analyzer must produce.
// Keyword/identifier
Expand Down Expand Up @@ -152,6 +166,7 @@ void setUp() throws Exception {
resolutionIndex(language),
"/elasticsearch/" + language + "/test_case_resolution_status_index_mapping.json");
indexDocument(resolutionIndex(language), RESOLUTION_ID, resolutionStatusDocument());
indexDocument(resolutionIndex(language), RESOLVED_ID, resolvedStatusDocument());
}
}

Expand Down Expand Up @@ -284,6 +299,63 @@ void incidentAssigneeFilterReturnsIncidentInAllLanguages() throws Exception {
== 1);
}

@Test
void incidentResolutionCommentSearchReturnsIncidentInAllLanguages() throws Exception {
assertFeatureWorksInAllLanguages(
"Incident Manager free-text search over the resolution comment (the boost list in "
+ "TestCaseResolutionStatusIndex.getFields(), used by "
+ "buildTestCaseResolutionStatusSearchV2)",
language ->
hits(
resolutionIndex(language),
boostedMultiMatchQuery(
FAILURE_COMMENT_TERM, TestCaseResolutionStatusIndex.getFields()))
== 1);
}

/**
* A type probe, not a product feature: nothing in the product filters on the failure reason today
* (the UI reads it from {@code _source}). It proves the mapping's declared {@code keyword} type is
* actually in effect — under dynamic mapping the field becomes analyzed {@code text} and an exact
* term can never match, which is the state this index was in.
*/
@Test
void failureReasonSupportsExactMatchTermInAllLanguages() throws Exception {
assertFeatureWorksInAllLanguages(
"Exact-match term support on testCaseResolutionStatusDetails.testCaseFailureReason "
+ "(the mapping must declare it as a keyword; left to dynamic mapping it becomes "
+ "analyzed text and no term ever matches)",
language ->
hits(
resolutionIndex(language),
termQuery(
"testCaseResolutionStatusDetails.testCaseFailureReason", FAILURE_REASON))
== 1);
}

@Test
void resolutionCommentUsesLanguageAnalyzerInAllLanguages() throws Exception {
List<String> broken = new ArrayList<>();
for (Map.Entry<String, String[]> entry : LANGUAGE_SAMPLE_TEXT.entrySet()) {
String language = entry.getKey();
List<String> tokens =
analyzeField(
resolutionIndex(language),
"testCaseResolutionStatusDetails.testCaseFailureComment",
entry.getValue()[0]);
if (!tokens.contains(entry.getValue()[1])) {
broken.add(language + " expected token '" + entry.getValue()[1] + "' but got " + tokens);
}
}
assertTrue(
broken.isEmpty(),
"The resolution comment must be indexed with the per-language analyzer the mapping "
+ "declares. A comment left to dynamic mapping falls back to the standard analyzer, "
+ "which cannot segment CJK or stem Russian — Incident Manager comment search is then "
+ "broken in that language: "
+ broken);
}

@Test
void incidentTestCaseFqnFilterReturnsIncidentInAllLanguages() throws Exception {
assertFeatureWorksInAllLanguages(
Expand Down Expand Up @@ -554,6 +626,41 @@ private String resolutionStatusDocument() throws Exception {
return mapper.writeValueAsString(document);
}

/**
* The Resolved branch of the {@code testCaseResolutionStatusDetails} oneOf, written flat exactly
* as the entity serializes (resolved.json declares testCaseFailureReason /
* testCaseFailureComment / resolvedBy directly and is additionalProperties=false, so no wrapper
* object can legally appear). Values are distinct from the Assigned document so the filters that
* expect a single incident still see one.
*
* <p>The denormalized {@code testCase} object that {@code
* TestCaseResolutionStatusIndex.setParentRelationships} always adds in production is omitted on
* purpose: adding it would give this document the same {@code testCase.fullyQualifiedName.keyword}
* / {@code testCase.entityFQN.keyword} values as the Assigned document, turning those two
* pre-existing single-hit assertions into two hits. {@code testCaseReference} carries the parent
* link instead — it is what the boosted field list actually reads.
*/
private String resolvedStatusDocument() throws Exception {
Map<String, Object> details =
Map.of(
"testCaseFailureReason", FAILURE_REASON,
"testCaseFailureComment", FAILURE_COMMENT,
"resolvedBy", Map.of("id", RESOLVER_ID, "type", "user", "name", RESOLVER_NAME));
Map<String, Object> document =
Map.ofEntries(
Map.entry("id", RESOLVED_ID),
Map.entry("entityType", "testCaseResolutionStatus"),
Map.entry("testCaseResolutionStatusType", RESOLVED_STATUS_TYPE),
Map.entry("testCaseResolutionStatusDetails", details),
Map.entry(
"testCaseReference",
Map.of("id", TEST_CASE_ID, "type", "testCase", "name", "amount_not_null")),
Map.entry("stateId", RESOLVED_STATE_ID),
Map.entry("@timestamp", 1700000000000L),
Map.entry("timestamp", 1700000000000L));
return mapper.writeValueAsString(document);
}

private Map<String, Object> tagLabel(String tagFqn) {
return Map.of(
"tagFQN", tagFqn,
Expand All @@ -566,6 +673,32 @@ private String termQuery(String field, String value) throws Exception {
return mapper.writeValueAsString(Map.of("query", Map.of("term", Map.of(field, value))));
}

/**
* The non-fuzzy branch of {@code SearchSourceBuilderFactory.buildSearchQueryBuilderV2} — a
* most_fields multi_match over the index's boosted field list. None of the resolution-status
* fields is a FUZZY_FIELD, so this is the whole query the Incident Manager search box issues.
* Fields come from production code, so repointing a boost at a path no document carries fails
* here.
*/
private String boostedMultiMatchQuery(String query, Map<String, Float> fields) throws Exception {
List<String> boostedFields =
fields.entrySet().stream().map(field -> field.getKey() + "^" + field.getValue()).toList();
return mapper.writeValueAsString(
Map.of(
"query",
Map.of(
"multi_match",
Map.of(
"query",
query,
"fields",
boostedFields,
"type",
"most_fields",
"operator",
"and"))));
}

private String nestedTermQuery(String path, String field, String value) throws Exception {
return mapper.writeValueAsString(
Map.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public static Map<String, Float> getFields() {
fields.put("testCaseReference.displayName", 15.0f);
fields.put("testCaseReference.name", 10.0f);
fields.put("testCaseReference.description", 1.0f);
fields.put("testCaseResolutionStatusDetails.resolved.testCaseFailureComment", 10.0f);
fields.put("testCaseResolutionStatusDetails.testCaseFailureComment", 10.0f);
return fields;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openmetadata.schema.utils.JsonUtils;
Expand All @@ -22,6 +24,9 @@
class IndexMappingNestedFieldConsistencyTest {

private static final List<String> LANGUAGES = List.of("en", "jp", "ru", "zh");
private static final String TESTS_SCHEMA_DIR = "json/schema/tests/";
private static final String RESOLUTION_STATUS_ENTITY = "testCaseResolutionStatus";
private static final String RESOLUTION_DETAILS_FIELD = "testCaseResolutionStatusDetails";
private static Map<String, JsonNode> allMappings;

@BeforeAll
Expand Down Expand Up @@ -207,6 +212,71 @@ void auditReportIndexFieldsMustBeMappedExplicitly() {
assertTrue(violations.isEmpty(), "Audit report mapping gaps: " + violations);
}

@Test
void resolutionStatusDetailsMappingMustMatchSchemaProperties() throws IOException {
Set<String> schemaProperties = resolutionDetailsSchemaProperties();
List<String> violations = new ArrayList<>();
for (String language : LANGUAGES) {
String entity = RESOLUTION_STATUS_ENTITY + "[" + language + "]";
JsonNode mapping = allMappings.get(entity);
JsonNode properties = mapping == null ? null : getTopLevelProperties(mapping);
assertNotNull(properties, "Index mapping for '" + entity + "' was not loaded.");
Set<String> mapped = fieldNames(properties.path(RESOLUTION_DETAILS_FIELD).path("properties"));
if (!mapped.equals(schemaProperties)) {
violations.add(entity + " maps " + mapped);
}
}
assertTrue(
violations.isEmpty(),
"The '"
+ RESOLUTION_DETAILS_FIELD
+ "' mapping must declare exactly the properties its schema allows "
+ schemaProperties
+ ". testCaseResolutionStatus.json models this field as a oneOf over closed "
+ "(additionalProperties=false) branches, so a mapped subfield the schema does not "
+ "declare can never match a document — the boost/filter that targets it is dead — and "
+ "a schema property the mapping omits is left to dynamic mapping, losing its declared "
+ "type and per-language analyzer. Violations: "
+ violations);
}

/**
* The union of the properties of every {@code oneOf} branch of {@code
* testCaseResolutionStatusDetails}, read from the JSON Schema so that adding a property to
* assigned.json / resolved.json fails this test until the mappings follow.
*/
private static Set<String> resolutionDetailsSchemaProperties() throws IOException {
JsonNode branches =
readSchema(TESTS_SCHEMA_DIR + "testCaseResolutionStatus.json")
.path("properties")
.path(RESOLUTION_DETAILS_FIELD)
.path("oneOf");
assertTrue(
branches.isArray() && !branches.isEmpty(), RESOLUTION_DETAILS_FIELD + " has no oneOf");
Set<String> properties = new TreeSet<>();
for (JsonNode branch : branches) {
String fileName = branch.path("$ref").asText().replaceFirst("^\\./", "");
properties.addAll(fieldNames(readSchema(TESTS_SCHEMA_DIR + fileName).path("properties")));
}
return properties;
}

private static JsonNode readSchema(String resource) throws IOException {
try (InputStream in =
IndexMappingNestedFieldConsistencyTest.class
.getClassLoader()
.getResourceAsStream(resource)) {
assertNotNull(in, "Schema resource not found on the classpath: " + resource);
return JsonUtils.readTree(new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
}

private static Set<String> fieldNames(JsonNode node) {
Set<String> names = new TreeSet<>();
node.fieldNames().forEachRemaining(names::add);
return names;
}

private static void findExtensionTypeViolations(
JsonNode properties, String currentPath, List<String> violations, String entity) {
Iterator<String> fieldNames = properties.fieldNames();
Expand Down
Loading
Loading