From da1ab3bd4c9c892e0cbfa894041bbb198afe7a24 Mon Sep 17 00:00:00 2001 From: TeddyCr Date: Wed, 19 Aug 2026 18:41:07 -0700 Subject: [PATCH 1/3] Fixes #28727: flatten testCaseResolutionStatusDetails mapping to match its schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolution-status index mapping declared a `resolved` wrapper object under `testCaseResolutionStatusDetails` that no document can ever carry. The schema models that field as a oneOf over assigned.json / resolved.json, and resolved.json declares testCaseFailureReason / testCaseFailureComment / resolvedBy directly with "additionalProperties": false — so a `resolved` key is illegal, not merely absent. Doc building is generic (SearchIndex uses JsonUtils.getMap and TestCaseResolutionStatusIndex only adds fqnParts/@timestamp/parent relationships), so the indexed document has always been flat. Consequences: the 10x boost on `testCaseResolutionStatusDetails.resolved.testCaseFailureComment` matched nothing in any language, and the real flat fields were only indexed by accident via "dynamic": true — as analyzed text with the standard analyzer, so exact-match on testCaseFailureReason failed and CJK/Russian comment search was broken. Move the three properties up one level in all four locale mappings and repoint the boost. Each locale keeps its own analyzer (om_analyzer for en/ru, om_analyzer_jp for jp, ik_max_word for zh) and jp/zh keep their fields.ngram subfield. "dynamic": true is kept: after flattening, every property of both oneOf branches is mapped explicitly, so it is no longer load-bearing, but it remains the forward-compatibility net that lets a newly added schema property be indexed rather than silently dropped. Flipping it is a separate hardening decision. Keeping it also gives un-reindexed clusters a partial improvement immediately, since the repointed boost now targets a path dynamic mapping already created. UPGRADE: this requires a reindex of test_case_resolution_status_search_index. No DB migration is involved — IndexMappingVersionTracker hashes each mapping and the smart-reindex plan recreates just the changed index. But `migrate` alone is not enough and will not silently no-op: on any cluster that has ever resolved an incident, dynamic mapping already created testCaseResolutionStatusDetails.testCaseFailureReason as text, so the PUT _mapping that updateIndexes() sends is rejected 400 on both engines with "mapper [...] cannot be changed from type [text] to [keyword]" (reproduced on OpenSearch 3.4.0 and Elasticsearch 9.3.0). The rejection is atomic, so no property in that request lands, and on the OpenSearch path the cause is swallowed by OpenSearchIndexManager's catch block. Only the recreate path applies the new mapping and drops the stale `resolved` sub-object. Co-Authored-By: Claude Opus 5 (1M context) --- .../TestCaseResolutionStatusIndex.java | 2 +- ..._case_resolution_status_index_mapping.json | 96 ++++++++-------- ..._case_resolution_status_index_mapping.json | 104 +++++++++--------- ..._case_resolution_status_index_mapping.json | 96 ++++++++-------- ..._case_resolution_status_index_mapping.json | 104 +++++++++--------- 5 files changed, 193 insertions(+), 209 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/TestCaseResolutionStatusIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/TestCaseResolutionStatusIndex.java index a7429b6e25ef..03c574afa7f5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/TestCaseResolutionStatusIndex.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/TestCaseResolutionStatusIndex.java @@ -78,7 +78,7 @@ public static Map 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; } } diff --git a/openmetadata-spec/src/main/resources/elasticsearch/en/test_case_resolution_status_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/en/test_case_resolution_status_index_mapping.json index 7350ef8900cf..4cc548381e9f 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/en/test_case_resolution_status_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/en/test_case_resolution_status_index_mapping.json @@ -167,73 +167,69 @@ } } }, - "resolved": { + "testCaseFailureReason": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 15 + } + } + }, + "testCaseFailureComment": { + "type": "text", + "analyzer": "om_analyzer", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + } + } + }, + "resolvedBy": { "properties": { - "testCaseFailureReason": { + "id": { "type": "keyword", "fields": { "keyword": { "type": "keyword", - "ignore_above": 15 + "ignore_above": 36 } } }, - "testCaseFailureComment": { - "type": "text", - "analyzer": "om_analyzer", + "type": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer", "fields": { "keyword": { "type": "keyword", - "normalizer": "lowercase_normalizer" + "ignore_above": 256 } } }, - "resolvedBy": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { + "displayName": { + "type": "keyword", + "fields": { + "keyword": { "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { - "type": "text" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" + "ignore_above": 256 } } + }, + "fullyQualifiedName": { + "type": "text" + }, + "description": { + "type": "text" + }, + "deleted": { + "type": "text" + }, + "href": { + "type": "text" } } } diff --git a/openmetadata-spec/src/main/resources/elasticsearch/jp/test_case_resolution_status_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/jp/test_case_resolution_status_index_mapping.json index 92575d0a8a27..3ad295800d2c 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/jp/test_case_resolution_status_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/jp/test_case_resolution_status_index_mapping.json @@ -175,77 +175,73 @@ } } }, - "resolved": { + "testCaseFailureReason": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 15 + } + } + }, + "testCaseFailureComment": { + "type": "text", + "analyzer": "om_analyzer_jp", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "ngram": { + "type": "text", + "analyzer": "om_ngram" + } + } + }, + "resolvedBy": { "properties": { - "testCaseFailureReason": { + "id": { "type": "keyword", "fields": { "keyword": { "type": "keyword", - "ignore_above": 15 + "ignore_above": 36 } } }, - "testCaseFailureComment": { - "type": "text", - "analyzer": "om_analyzer_jp", + "type": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer", "fields": { "keyword": { "type": "keyword", - "normalizer": "lowercase_normalizer" - }, - "ngram": { - "type": "text", - "analyzer": "om_ngram" + "ignore_above": 256 } } }, - "resolvedBy": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { + "displayName": { + "type": "keyword", + "fields": { + "keyword": { "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { - "type": "text" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" + "ignore_above": 256 } } + }, + "fullyQualifiedName": { + "type": "text" + }, + "description": { + "type": "text" + }, + "deleted": { + "type": "text" + }, + "href": { + "type": "text" } } } diff --git a/openmetadata-spec/src/main/resources/elasticsearch/ru/test_case_resolution_status_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/ru/test_case_resolution_status_index_mapping.json index b1ac9fec5a5a..9729fcdc8f0b 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/ru/test_case_resolution_status_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/ru/test_case_resolution_status_index_mapping.json @@ -181,73 +181,69 @@ } } }, - "resolved": { + "testCaseFailureReason": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 15 + } + } + }, + "testCaseFailureComment": { + "type": "text", + "analyzer": "om_analyzer", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + } + } + }, + "resolvedBy": { "properties": { - "testCaseFailureReason": { + "id": { "type": "keyword", "fields": { "keyword": { "type": "keyword", - "ignore_above": 15 + "ignore_above": 36 } } }, - "testCaseFailureComment": { - "type": "text", - "analyzer": "om_analyzer", + "type": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer", "fields": { "keyword": { "type": "keyword", - "normalizer": "lowercase_normalizer" + "ignore_above": 256 } } }, - "resolvedBy": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { + "displayName": { + "type": "keyword", + "fields": { + "keyword": { "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { - "type": "text" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" + "ignore_above": 256 } } + }, + "fullyQualifiedName": { + "type": "text" + }, + "description": { + "type": "text" + }, + "deleted": { + "type": "text" + }, + "href": { + "type": "text" } } } diff --git a/openmetadata-spec/src/main/resources/elasticsearch/zh/test_case_resolution_status_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/zh/test_case_resolution_status_index_mapping.json index 03363a29c939..2e2b267a3117 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/zh/test_case_resolution_status_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/zh/test_case_resolution_status_index_mapping.json @@ -165,77 +165,73 @@ } } }, - "resolved": { + "testCaseFailureReason": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 15 + } + } + }, + "testCaseFailureComment": { + "type": "text", + "analyzer": "ik_max_word", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "ngram": { + "type": "text", + "analyzer": "om_ngram" + } + } + }, + "resolvedBy": { "properties": { - "testCaseFailureReason": { + "id": { "type": "keyword", "fields": { "keyword": { "type": "keyword", - "ignore_above": 15 + "ignore_above": 36 } } }, - "testCaseFailureComment": { - "type": "text", - "analyzer": "ik_max_word", + "type": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer", "fields": { "keyword": { "type": "keyword", - "normalizer": "lowercase_normalizer" - }, - "ngram": { - "type": "text", - "analyzer": "om_ngram" + "ignore_above": 256 } } }, - "resolvedBy": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { + "displayName": { + "type": "keyword", + "fields": { + "keyword": { "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { - "type": "text" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" + "ignore_above": 256 } } + }, + "fullyQualifiedName": { + "type": "text" + }, + "description": { + "type": "text" + }, + "deleted": { + "type": "text" + }, + "href": { + "type": "text" } } } From 6d3b76021c13221b532a5023896b86df8ec887de Mon Sep 17 00:00:00 2001 From: TeddyCr Date: Wed, 19 Aug 2026 18:41:12 -0700 Subject: [PATCH 2/3] Fixes #28727: cover the Resolved incident branch in search behavior tests Nothing indexed a Resolved-variant resolution-status document and nothing asserted the boost field list, which is why the dead `resolved` wrapper survived. SearchConsumerFieldBehaviorIT now indexes both oneOf branches (Assigned and Resolved) into the real per-language OpenSearch mappings and adds three probes: - the Incident Manager free-text search, built from TestCaseResolutionStatusIndex.getFields() so repointing a boost at a path no document carries fails the test; - an exact-match term on testCaseFailureReason, which distinguishes an explicitly mapped keyword from a field merely picked up by "dynamic": true; - _analyze on testCaseFailureComment per locale, which pins the per-locale analyzer and would catch a flatten that homogenized them. IndexMappingNestedFieldConsistencyTest gains a mapping-to-schema invariant that reads the oneOf branch schemas at runtime rather than hardcoding field names, so adding a property to assigned.json / resolved.json fails until the mappings follow. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/SearchConsumerFieldBehaviorIT.java | 126 ++++++++++++++++++ ...ndexMappingNestedFieldConsistencyTest.java | 70 ++++++++++ 2 files changed, 196 insertions(+) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java index 85e8f9dc0e06..697ef775794c 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java @@ -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; @@ -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. + * + *

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) @@ -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 @@ -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()); } } @@ -284,6 +299,56 @@ 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); + } + + @Test + void incidentFailureReasonFilterReturnsIncidentInAllLanguages() throws Exception { + assertFeatureWorksInAllLanguages( + "Incident Manager failure-reason exact match " + + "(testCaseResolutionStatusDetails.testCaseFailureReason as a keyword)", + language -> + hits( + resolutionIndex(language), + termQuery( + "testCaseResolutionStatusDetails.testCaseFailureReason", FAILURE_REASON)) + == 1); + } + + @Test + void resolutionCommentUsesLanguageAnalyzerInAllLanguages() throws Exception { + List broken = new ArrayList<>(); + for (Map.Entry entry : LANGUAGE_SAMPLE_TEXT.entrySet()) { + String language = entry.getKey(); + List 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( @@ -554,6 +619,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. + * + *

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 details = + Map.of( + "testCaseFailureReason", FAILURE_REASON, + "testCaseFailureComment", FAILURE_COMMENT, + "resolvedBy", Map.of("id", RESOLVER_ID, "type", "user", "name", RESOLVER_NAME)); + Map 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 tagLabel(String tagFqn) { return Map.of( "tagFQN", tagFqn, @@ -566,6 +666,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 fields) throws Exception { + List 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( diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/search/IndexMappingNestedFieldConsistencyTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/search/IndexMappingNestedFieldConsistencyTest.java index cbfffae691af..4b0a9395f655 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/search/IndexMappingNestedFieldConsistencyTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/search/IndexMappingNestedFieldConsistencyTest.java @@ -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; @@ -22,6 +24,9 @@ class IndexMappingNestedFieldConsistencyTest { private static final List 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 allMappings; @BeforeAll @@ -207,6 +212,71 @@ void auditReportIndexFieldsMustBeMappedExplicitly() { assertTrue(violations.isEmpty(), "Audit report mapping gaps: " + violations); } + @Test + void resolutionStatusDetailsMappingMustMatchSchemaProperties() throws IOException { + Set schemaProperties = resolutionDetailsSchemaProperties(); + List 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 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 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 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 fieldNames(JsonNode node) { + Set names = new TreeSet<>(); + node.fieldNames().forEachRemaining(names::add); + return names; + } + private static void findExtensionTypeViolations( JsonNode properties, String currentPath, List violations, String entity) { Iterator fieldNames = properties.fieldNames(); From 08756c784b3ef220e3b4aebccc274962cc9cc438 Mon Sep 17 00:00:00 2001 From: TeddyCr Date: Wed, 19 Aug 2026 19:12:59 -0700 Subject: [PATCH 3/3] Fixes #28727: name the failure-reason test for what it proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incidentFailureReasonFilterReturnsIncidentInAllLanguages reported "Incident Manager failure-reason exact match ... is broken in language(s)", but no production search query filters on testCaseResolutionStatusDetails.testCaseFailureReason — the UI reads it from _source, and IncidentTcrsSyncHandler / TestCaseResolutionStatusRepository read it from the task payload. The name and message would send someone hunting for a broken feature that does not exist. The test is valuable as a mapping-type probe: it is what distinguishes an explicitly declared keyword from a field merely picked up by "dynamic": true as analyzed text, on which an exact term can never match. Rename it to failureReasonSupportsExactMatchTermInAllLanguages, reword the assertion to describe the type guarantee rather than a feature, and add a javadoc saying why the probe exists. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- .../it/tests/SearchConsumerFieldBehaviorIT.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java index 697ef775794c..5e01855f4e3f 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchConsumerFieldBehaviorIT.java @@ -313,11 +313,18 @@ void incidentResolutionCommentSearchReturnsIncidentInAllLanguages() throws Excep == 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 incidentFailureReasonFilterReturnsIncidentInAllLanguages() throws Exception { + void failureReasonSupportsExactMatchTermInAllLanguages() throws Exception { assertFeatureWorksInAllLanguages( - "Incident Manager failure-reason exact match " - + "(testCaseResolutionStatusDetails.testCaseFailureReason as a keyword)", + "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),