From a630ca2be64d7b4907d5dc002e55641fa085c229 Mon Sep 17 00:00:00 2001 From: IceS2 Date: Mon, 22 Sep 2025 21:38:49 +0200 Subject: [PATCH] Fixes #23330: Implement Fetch DQ Test Cases by FollowedBy (#23461) * Implement Fetch DQ Test Cases by FollowedBy * Update generated TypeScript types * Fix wrong conflict resolution * Refactored into Optional --------- Co-authored-by: github-actions[bot] --- .../service/jdbi3/EntityRepository.java | 14 + .../service/jdbi3/TestCaseRepository.java | 5 +- .../resources/dqtests/TestCaseResource.java | 253 +++++++++++++----- .../service/search/SearchClient.java | 47 ++++ .../service/search/SearchListFilter.java | 41 ++- .../service/search/SearchRepository.java | 24 ++ .../service/resources/EntityResourceTest.java | 41 ++- .../dqtests/TestCaseResourceTest.java | 176 ++++++++++++ .../resources/json/schema/tests/testCase.json | 4 + .../ui/src/generated/tests/testCase.ts | 5 + 10 files changed, 525 insertions(+), 85 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 2f82c95b0ad..d74526691e4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -1491,6 +1491,8 @@ public abstract class EntityRepository { entity.setIncrementalChangeDescription(change); entity.setChangeDescription(change); + // Populate followers before postUpdate to ensure propagation to children + entity.setFollowers(getFollowers(entity)); postUpdate(entity, entity); return new PutResponse<>(Status.OK, changeEvent, ENTITY_FIELDS_CHANGED); } @@ -1966,6 +1968,8 @@ public abstract class EntityRepository { entity.setChangeDescription(change); entity.setIncrementalChangeDescription(change); + // Populate followers before postUpdate to ensure propagation to children + entity.setFollowers(getFollowers(entity)); postUpdate(entity, entity); return new PutResponse<>(Status.OK, changeEvent, ENTITY_FIELDS_CHANGED); } @@ -3060,6 +3064,16 @@ public abstract class EntityRepository { } } + public final void inheritFollowers(T entity, Fields fields, EntityInterface parent) { + if (fields.contains(FIELD_FOLLOWERS) && nullOrEmpty(entity.getFollowers()) && parent != null) { + entity.setFollowers( + Optional.ofNullable(parent.getFollowers()) + .filter(list -> !list.isEmpty()) + .orElse(Collections.emptyList())); + listOrEmpty(entity.getFollowers()).forEach(follower -> follower.setInherited(true)); + } + } + public final void inheritOwners(T entity, Fields fields, EntityInterface parent) { if (fields.contains(FIELD_OWNERS) && nullOrEmpty(entity.getOwners()) && parent != null) { entity.setOwners(getInheritedOwners(entity, fields, parent)); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java index 37252abfa89..0dfcee501df 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java @@ -366,12 +366,15 @@ public class TestCaseRepository extends EntityRepository { // Inherit from the table/column EntityInterface tableOrColumn = Entity.getEntity( - EntityLink.parse(testCase.getEntityLink()), "owners,domains,tags,columns", ALL); + EntityLink.parse(testCase.getEntityLink()), + "owners,domains,tags,columns,followers", + ALL); if (tableOrColumn != null) { inheritOwners(testCase, fields, tableOrColumn); inheritDomains(testCase, fields, tableOrColumn); if (tableOrColumn instanceof Table) { inheritTags(testCase, fields, (Table) tableOrColumn); + inheritFollowers(testCase, fields, (Table) tableOrColumn); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java index 991f8f35e00..ce23ec58a10 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java @@ -95,9 +95,9 @@ public class TestCaseResource extends EntityResource t.getId().toString()) - .collect(Collectors.joining(","))); - } - } catch (Exception e) { - // If the owner is not a user, then we'll try to get team - entity = Entity.getEntityByName(Entity.TEAM, owner, "", ALL); - owners.append(entity.getId().toString()); - } - searchListFilter.addQueryParam("owners", owners.toString()); - } - - if (startTimestamp != null) { - if (startTimestamp > endTimestamp) { - throw new IllegalArgumentException("startTimestamp must be less than endTimestamp"); - } - searchListFilter.addQueryParam("startTimestamp", startTimestamp.toString()); - searchListFilter.addQueryParam("endTimestamp", endTimestamp.toString()); - } - - ResourceContextInterface resourceContextInterface = - getResourceContext(entityLink, searchListFilter); - // Override OperationContext to change the entity to table and operation from VIEW_ALL to - // VIEW_TESTS - OperationContext operationContext = - new OperationContext(Entity.TABLE, MetadataOperation.VIEW_TESTS); - Fields fields = getFields(fieldsParam); - - ResultList tests = - super.listInternalFromSearch( - uriInfo, - securityContext, - fields, - searchListFilter, - limit, - offset, - searchSortFilter, + SearchListFilter searchListFilter = + buildSearchListFilter( + include, + testSuiteId, + includeAllTests, + status, + type, + testPlatforms, + dataQualityDimension, q, - queryString, - operationContext, - resourceContextInterface); - return PIIMasker.getTestCases(tests, authorizer, securityContext); + includeFields, + domain, + tags, + tier, + serviceName, + createdBy, + owner, + followedBy, + startTimestamp, + endTimestamp); + + // Execute search + return executeTestCaseSearch( + uriInfo, + securityContext, + fieldsParam, + entityLink, + searchListFilter, + searchSortFilter, + limit, + offset, + q, + queryString); } @GET @@ -1157,4 +1128,140 @@ public class TestCaseResource extends EntityResource t.getId().toString()) + .collect(Collectors.joining(","))); + } + } catch (Exception e) { + // If not a user, try to resolve as a team + EntityInterface entity = Entity.getEntityByName(Entity.TEAM, userOrTeamName, "", ALL); + ids.append(entity.getId().toString()); + } + + return ids.toString(); + } + + private static void validateTimestamps(Long startTimestamp, Long endTimestamp) { + if ((startTimestamp == null && endTimestamp != null) + || (startTimestamp != null && endTimestamp == null)) { + throw new IllegalArgumentException("startTimestamp and endTimestamp must be used together"); + } + if (startTimestamp != null && startTimestamp > endTimestamp) { + throw new IllegalArgumentException("startTimestamp must be less than endTimestamp"); + } + } + + private static SearchListFilter buildSearchListFilter( + Include include, + String testSuiteId, + Boolean includeAllTests, + String status, + String type, + String testPlatforms, + String dataQualityDimension, + String q, + String includeFields, + String domain, + String tags, + String tier, + String serviceName, + String createdBy, + String owner, + String followedBy, + Long startTimestamp, + Long endTimestamp) { + + SearchListFilter searchListFilter = new SearchListFilter(include); + + // Add basic parameters + searchListFilter.addQueryParam("testSuiteId", testSuiteId); + searchListFilter.addQueryParam("includeAllTests", includeAllTests.toString()); + searchListFilter.addQueryParam("testCaseStatus", status); + searchListFilter.addQueryParam("testCaseType", type); + searchListFilter.addQueryParam("testPlatforms", testPlatforms); + searchListFilter.addQueryParam("dataQualityDimension", dataQualityDimension); + searchListFilter.addQueryParam("q", q); + searchListFilter.addQueryParam("excludeFields", SEARCH_FIELDS_EXCLUDE); + searchListFilter.addQueryParam("includeFields", includeFields); + searchListFilter.addQueryParam("domains", domain); + searchListFilter.addQueryParam("tags", tags); + searchListFilter.addQueryParam("tier", tier); + searchListFilter.addQueryParam("serviceName", serviceName); + searchListFilter.addQueryParam("createdBy", createdBy); + + // Handle owner and followedBy parameters + if (!nullOrEmpty(owner)) { + String ownerIds = resolveUserOrTeamIds(owner, true); // include team members + searchListFilter.addQueryParam("owners", ownerIds); + } + if (!nullOrEmpty(followedBy)) { + String followerIds = resolveUserOrTeamIds(followedBy, false); // don't include team members + searchListFilter.addQueryParam("followedBy", followerIds); + } + + // Add timestamp parameters + if (startTimestamp != null) { + searchListFilter.addQueryParam("startTimestamp", startTimestamp.toString()); + searchListFilter.addQueryParam("endTimestamp", endTimestamp.toString()); + } + + return searchListFilter; + } + + private ResultList executeTestCaseSearch( + UriInfo uriInfo, + SecurityContext securityContext, + String fieldsParam, + String entityLink, + SearchListFilter searchListFilter, + SearchSortFilter searchSortFilter, + int limit, + int offset, + String q, + String queryString) + throws IOException { + + ResourceContextInterface resourceContextInterface = + getResourceContext(entityLink, searchListFilter); + + // Override OperationContext to change the entity to table and operation from VIEW_ALL to + // VIEW_TESTS + OperationContext operationContext = + new OperationContext(Entity.TABLE, MetadataOperation.VIEW_TESTS); + Fields fields = getFields(fieldsParam); + + ResultList tests = + super.listInternalFromSearch( + uriInfo, + securityContext, + fields, + searchListFilter, + limit, + offset, + searchSortFilter, + q, + queryString, + operationContext, + resourceContextInterface); + + return PIIMasker.getTestCases(tests, authorizer, securityContext); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java index f749e47fc89..4294da3ee41 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java @@ -231,6 +231,53 @@ public interface SearchClient { } """; + // Script for propagating followers to TestCases from their parent tables. + // TestCases can only have inherited followers (no direct followers allowed), + // so we always replace the entire followers list when propagating. + // Followers are stored as UUID strings in the search index for efficiency. + // This script only applies to TestCases - does nothing for other entity types. + String ADD_FOLLOWERS_SCRIPT = + """ + if (ctx._source.containsKey('entityType') && ctx._source.entityType == 'testCase') { + // TestCases can only have inherited followers - always replace + if (params.containsKey('updatedFollowers') && params.updatedFollowers != null) { + List followerIds = new ArrayList(); + for (def follower : params.updatedFollowers) { + if (follower != null && follower.containsKey('id')) { + followerIds.add(follower.id.toString()); + } + } + ctx._source.followers = followerIds; + } + } + // Do nothing for other entity types + """; + + // Script for removing followers from TestCases when removed from their parent tables. + // TestCases can only have inherited followers, so when the parent loses followers, + // we need to update the TestCase's follower list accordingly. + // Note: deletedFollowers contains the REMAINING followers after deletion, not the deleted ones. + // This script only applies to TestCases - does nothing for other entity types. + String REMOVE_FOLLOWERS_SCRIPT = + """ + if (ctx._source.containsKey('entityType') && ctx._source.entityType == 'testCase') { + // For TestCases, replace with the updated follower list (already has removed followers filtered out) + if (params.containsKey('deletedFollowers') && params.deletedFollowers != null) { + List followerIds = new ArrayList(); + for (def follower : params.deletedFollowers) { + if (follower != null && follower.containsKey('id')) { + followerIds.add(follower.id.toString()); + } + } + ctx._source.followers = followerIds; + } else { + // If no followers remain, clear the list + ctx._source.followers = new ArrayList(); + } + } + // Do nothing for other entity types + """; + String UPDATE_TAGS_FIELD_SCRIPT = """ if (ctx._source.tags != null) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchListFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchListFilter.java index 84e2ce18737..58cbb7f86cd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchListFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchListFilter.java @@ -21,6 +21,18 @@ public class SearchListFilter extends Filter { static final String SEARCH_LIST_FILTER_EXCLUDE = "fqnParts,entityType,suggest"; + // Elasticsearch field name constants + private static final String FIELD_DELETED = "deleted"; + private static final String FIELD_OWNERS_ID = "owners.id"; + private static final String FIELD_CREATED_BY = "createdBy"; + private static final String FIELD_DOMAINS_FQN = "domains.fullyQualifiedName"; + private static final String FIELD_SERVICE_NAME = "service.name"; + private static final String FIELD_TEST_CASE_STATUS = "testCaseResult.testCaseStatus"; + private static final String FIELD_TEST_PLATFORMS = "testPlatforms"; + private static final String FIELD_FOLLOWERS_KEYWORD = "followers.keyword"; + private static final String FIELD_TEST_STATUS = "testCaseStatus"; + private static final String FIELD_BASIC = "basic"; + @Override public String getCondition(String entityType) { String conditionFilter = buildConditionFilter(entityType); @@ -115,7 +127,7 @@ public class SearchListFilter extends Filter { String domain = getQueryParam("domains"); if (!nullOrEmpty(domain)) { return String.format( - "{\"term\": {\"domains.fullyQualifiedName\": \"%s\"}}", escapeDoubleQuotes(domain)); + "{\"term\": {\"%s\": \"%s\"}}", FIELD_DOMAINS_FQN, escapeDoubleQuotes(domain)); } return ""; } @@ -129,7 +141,8 @@ public class SearchListFilter extends Filter { } String deleted = ""; if (include != Include.ALL && supportsDeleted) { - deleted = String.format("{\"term\": {\"deleted\": \"%s\"}}", include == Include.DELETED); + deleted = + String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_DELETED, include == Include.DELETED); } return deleted; } @@ -139,7 +152,7 @@ public class SearchListFilter extends Filter { if (!nullOrEmpty(owners)) { String ownersList = Arrays.stream(owners.split(",")).collect(Collectors.joining("\", \"", "\"", "\"")); - return String.format("{\"terms\": {\"owners.id\": [%s]}}", ownersList); + return String.format("{\"terms\": {\"%s\": [%s]}}", FIELD_OWNERS_ID, ownersList); } return ""; } @@ -147,7 +160,8 @@ public class SearchListFilter extends Filter { private String getCreatedByCondition() { String createdBy = getQueryParam("createdBy"); if (!nullOrEmpty(createdBy)) { - return String.format("{\"term\": {\"createdBy\": \"%s\"}}", escapeDoubleQuotes(createdBy)); + return String.format( + "{\"term\": {\"%s\": \"%s\"}}", FIELD_CREATED_BY, escapeDoubleQuotes(createdBy)); } return ""; } @@ -200,6 +214,7 @@ public class SearchListFilter extends Filter { String tier = getQueryParam("tier"); String serviceName = getQueryParam("serviceName"); String dataQualityDimension = getQueryParam("dataQualityDimension"); + String followedBy = getQueryParam("followedBy"); if (tags != null) { String tagsList = @@ -221,7 +236,8 @@ public class SearchListFilter extends Filter { if (serviceName != null) { conditions.add( - String.format("{\"term\": {\"service.name\": \"%s\"}}", escapeDoubleQuotes(serviceName))); + String.format( + "{\"term\": {\"%s\": \"%s\"}}", FIELD_SERVICE_NAME, escapeDoubleQuotes(serviceName))); } if (entityFQN != null) { @@ -235,8 +251,7 @@ public class SearchListFilter extends Filter { if (testSuiteId != null) conditions.add(getTestSuiteIdCondition(testSuiteId)); if (status != null) { - conditions.add( - String.format("{\"term\": {\"testCaseResult.testCaseStatus\": \"%s\"}}", status)); + conditions.add(String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_TEST_CASE_STATUS, status)); } if (type != null) conditions.add(getTestCaseTypeCondition(type, "entityLink")); @@ -244,7 +259,7 @@ public class SearchListFilter extends Filter { if (testPlatform != null) { String platforms = Arrays.stream(testPlatform.split(",")).collect(Collectors.joining("\", \"", "\"", "\"")); - conditions.add(String.format("{\"terms\": {\"testPlatforms\": [%s]}}", platforms)); + conditions.add(String.format("{\"terms\": {\"%s\": [%s]}}", FIELD_TEST_PLATFORMS, platforms)); } if (startTimestamp != null && endTimestamp != null) { @@ -258,6 +273,11 @@ public class SearchListFilter extends Filter { conditions.add( getDataQualityDimensionCondition(dataQualityDimension, "dataQualityDimension")); + if (followedBy != null) { + conditions.add( + String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_FOLLOWERS_KEYWORD, followedBy)); + } + return addCondition(conditions); } @@ -289,7 +309,8 @@ public class SearchListFilter extends Filter { escapeDoubleQuotes(testCaseFQN))); } if (testCaseStatus != null) - conditions.add(String.format("{\"term\": {\"testCaseStatus\": \"%s\"}}", testCaseStatus)); + conditions.add( + String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_TEST_STATUS, testCaseStatus)); if (type != null) conditions.add(getTestCaseTypeCondition(type, "testCase.entityLink")); if (testSuiteId != null) conditions.add(getTestSuiteIdCondition(testSuiteId)); if (dataQualityDimension != null) @@ -308,7 +329,7 @@ public class SearchListFilter extends Filter { if (testSuiteType != null) { boolean basic = !testSuiteType.equals("logical"); - conditions.add(String.format("{\"term\": {\"basic\": \"%s\"}}", basic)); + conditions.add(String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_BASIC, basic)); } if (!includeEmptyTestSuites) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java index 1de36983ae5..7a9e23a8e34 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java @@ -16,6 +16,7 @@ import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA; import static org.openmetadata.service.Entity.WEB_ANALYTIC_ENTITY_VIEW_REPORT_DATA; import static org.openmetadata.service.Entity.WEB_ANALYTIC_USER_ACTIVITY_REPORT_DATA; import static org.openmetadata.service.search.SearchClient.ADD_DOMAINS_SCRIPT; +import static org.openmetadata.service.search.SearchClient.ADD_FOLLOWERS_SCRIPT; import static org.openmetadata.service.search.SearchClient.ADD_OWNERS_SCRIPT; import static org.openmetadata.service.search.SearchClient.DATA_ASSET_SEARCH_ALIAS; import static org.openmetadata.service.search.SearchClient.DEFAULT_UPDATE_SCRIPT; @@ -28,6 +29,7 @@ import static org.openmetadata.service.search.SearchClient.REMOVE_DATA_PRODUCTS_ import static org.openmetadata.service.search.SearchClient.REMOVE_DOMAINS_CHILDREN_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_DOMAINS_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_ENTITY_RELATIONSHIP; +import static org.openmetadata.service.search.SearchClient.REMOVE_FOLLOWERS_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_OWNERS_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_PROPAGATED_ENTITY_REFERENCE_FIELD_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_PROPAGATED_FIELD_SCRIPT; @@ -139,6 +141,7 @@ public class SearchRepository { List.of( FIELD_OWNERS, FIELD_DOMAINS, + FIELD_FOLLOWERS, Entity.FIELD_DISABLED, Entity.FIELD_TEST_SUITES, FIELD_DISPLAY_NAME); @@ -781,6 +784,12 @@ public class SearchRepository { fieldData.put("deletedDomains", inheritedDomains); scriptTxt.append(REMOVE_DOMAINS_SCRIPT); scriptTxt.append(" "); + } else if (field.getName().equals(FIELD_FOLLOWERS)) { + List inheritedFollowers = + copyWithInheritedFlag(entity.getFollowers()); + fieldData.put("deletedFollowers", inheritedFollowers); + scriptTxt.append(REMOVE_FOLLOWERS_SCRIPT); + scriptTxt.append(" "); } else { EntityReference entityReference = JsonUtils.readValue(field.getOldValue().toString(), EntityReference.class); @@ -852,6 +861,11 @@ public class SearchRepository { } fieldData.put("updatedDomains", inheritedDomains); scriptTxt.append(ADD_DOMAINS_SCRIPT); + } else if (field.getName().equals(FIELD_FOLLOWERS)) { + List inheritedFollowers = + copyWithInheritedFlag(entity.getFollowers()); + fieldData.put("updatedFollowers", inheritedFollowers); + scriptTxt.append(ADD_FOLLOWERS_SCRIPT); } else { EntityReference entityReference = JsonUtils.readValue(field.getNewValue().toString(), EntityReference.class); @@ -1497,4 +1511,14 @@ public class SearchRepository { return searchClient.getSchemaEntityRelationship( schemaFqn, queryFilter, includeSourceFields, offset, limit, from, size, deleted); } + + private static List copyWithInheritedFlag(List references) { + if (references == null || references.isEmpty()) { + return new ArrayList<>(); + } + List inheritedReferences = + JsonUtils.deepCopyList(references, EntityReference.class); + inheritedReferences.forEach(ref -> ref.setInherited(true)); + return inheritedReferences; + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java index 682898c310e..d44c1c02bac 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java @@ -264,7 +264,7 @@ public abstract class EntityResourceTest { + Map doc = getEntityDocumentFromSearch(entityId, entityType); + Object actualValue = doc.get(fieldName); + + // Handle null comparisons + if (expectedValue == null) { + return actualValue == null; + } + + // For collections, compare contents + if (expectedValue instanceof List && actualValue instanceof List) { + List expectedList = (List) expectedValue; + List actualList = (List) actualValue; + return expectedList.size() == actualList.size() + && expectedList.containsAll(actualList) + && actualList.containsAll(expectedList); + } + + // For other types, use equals + return expectedValue.equals(actualValue); + }); + } + public static Map getEntityDocumentFromSearch(UUID entityId, String entityType) throws HttpResponseException { IndexMapping indexMapping = Entity.getSearchRepository().getIndexMapping(entityType); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java index 052fe7ea7a2..3c25a1d2b54 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java @@ -195,6 +195,8 @@ public class TestCaseResourceTest extends EntityResourceTest listTestCasesFromSearch( + Map queryParams, + Integer limit, + Integer offset, + Map authHeader) + throws HttpResponseException { + WebTarget target = getCollection().path("/search/list"); + for (Map.Entry entry : queryParams.entrySet()) { + target = target.queryParam(entry.getKey(), entry.getValue()); + } + target = limit != null ? target.queryParam("limit", limit) : target; + target = offset != null ? target.queryParam("offset", offset) : target; + return TestUtils.get(target, TestCaseResource.TestCaseList.class, authHeader); + } + protected void validateListTestCaseResultsFromSearchWithPagination( Map queryParams, Integer maxEntities, String path) throws IOException { // List all entities and use it for checking pagination @@ -4139,6 +4156,165 @@ public class TestCaseResourceTest extends EntityResourceTest", table.getFullyQualifiedName())) + .withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName()) + .withParameterValues( + List.of(new TestCaseParameterValue().withValue("10").withName("minValue"))); + TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS); + + // Wait for search index sync + Map sourceAsMap = + waitForSyncAndGetFromSearchIndex( + testCase.getUpdatedAt(), testCase.getId(), Entity.TEST_CASE); + + // Verify the test case inherited the follower from the table + List followers = (List) sourceAsMap.get("followers"); + assertNotNull(followers); + assertEquals(1, followers.size()); + assertEquals(USER1.getId().toString(), followers.get(0)); + } + + @Test + void test_listTestCasesWithFollowedByFilter(TestInfo testInfo) + throws IOException, InterruptedException { + // Create two tables with different followers + TableResourceTest tableResourceTest = new TableResourceTest(); + CreateTable tableReq1 = + tableResourceTest + .createRequest(testInfo, 1) + .withColumns( + List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT))); + Table table1 = tableResourceTest.createAndCheckEntity(tableReq1, ADMIN_AUTH_HEADERS); + + CreateTable tableReq2 = + tableResourceTest + .createRequest(testInfo, 2) + .withColumns( + List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT))); + Table table2 = tableResourceTest.createAndCheckEntity(tableReq2, ADMIN_AUTH_HEADERS); + + // Add USER1 as follower to table1 + tableResourceTest.addFollower(table1.getId(), USER1_REF.getId(), OK, ADMIN_AUTH_HEADERS); + + // Add USER2 as follower to table2 + tableResourceTest.addFollower(table2.getId(), USER2_REF.getId(), OK, ADMIN_AUTH_HEADERS); + + // Create test cases for both tables + CreateTestCase create1 = + createRequest(testInfo, 1) + .withEntityLink(String.format("<#E::table::%s>", table1.getFullyQualifiedName())) + .withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName()) + .withParameterValues( + List.of(new TestCaseParameterValue().withValue("10").withName("minValue"))); + TestCase testCase1 = createAndCheckEntity(create1, ADMIN_AUTH_HEADERS); + + CreateTestCase create2 = + createRequest(testInfo, 2) + .withEntityLink(String.format("<#E::table::%s>", table2.getFullyQualifiedName())) + .withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName()) + .withParameterValues( + List.of(new TestCaseParameterValue().withValue("10").withName("minValue"))); + TestCase testCase2 = createAndCheckEntity(create2, ADMIN_AUTH_HEADERS); + + // Wait for test cases to be properly indexed with their inherited followers + // TestCase1 should inherit USER1 from table1 + waitForFieldInSearchIndex( + testCase1.getId(), Entity.TEST_CASE, "followers", List.of(USER1.getId().toString())); + + // TestCase2 should inherit USER2 from table2 + waitForFieldInSearchIndex( + testCase2.getId(), Entity.TEST_CASE, "followers", List.of(USER2.getId().toString())); + + // Test filtering by USER1 + Map queryParams = new HashMap<>(); + queryParams.put("followedBy", USER1.getName()); + ResultList results = listTestCasesFromSearch(queryParams, 10, 0, ADMIN_AUTH_HEADERS); + + // Should find test cases from table1 (followed by USER1) + assertTrue( + results.getData().stream() + .anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase1.getFullyQualifiedName())), + "TestCase1 should be found when filtering by USER1"); + assertFalse( + results.getData().stream() + .anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase2.getFullyQualifiedName())), + "TestCase2 should not be found when filtering by USER1"); + + // Test filtering by USER2 + queryParams.put("followedBy", USER2.getName()); + results = listTestCasesFromSearch(queryParams, 10, 0, ADMIN_AUTH_HEADERS); + + // Should find test cases from table2 (followed by USER2) + assertFalse( + results.getData().stream() + .anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase1.getFullyQualifiedName())), + "TestCase1 should not be found when filtering by USER2"); + assertTrue( + results.getData().stream() + .anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase2.getFullyQualifiedName())), + "TestCase2 should be found when filtering by USER2"); + } + + @Test + void test_followerInheritanceAfterTableUpdate(TestInfo testInfo) + throws IOException, InterruptedException { + // Create a table without followers + TableResourceTest tableResourceTest = new TableResourceTest(); + CreateTable tableReq = + tableResourceTest + .createRequest(testInfo) + .withColumns( + List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT))); + Table table = tableResourceTest.createAndCheckEntity(tableReq, ADMIN_AUTH_HEADERS); + + // Create a test case for this table + CreateTestCase create = + createRequest(testInfo) + .withEntityLink(String.format("<#E::table::%s>", table.getFullyQualifiedName())) + .withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName()) + .withParameterValues( + List.of(new TestCaseParameterValue().withValue("10").withName("minValue"))); + TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS); + + // Wait for initial sync + waitForSyncAndGetFromSearchIndex(testCase.getUpdatedAt(), testCase.getId(), Entity.TEST_CASE); + + // Now add USER1 as follower to the table + tableResourceTest.addFollower(table.getId(), USER1_REF.getId(), OK, ADMIN_AUTH_HEADERS); + + // Wait for the follower to propagate to the test case in the search index + List expectedFollowers = List.of(USER1.getId().toString()); + waitForFieldInSearchIndex(testCase.getId(), Entity.TEST_CASE, "followers", expectedFollowers); + + // Verify test cases now show up when filtering by USER1 + Map queryParams = new HashMap<>(); + queryParams.put("followedBy", USER1.getName()); + ResultList results = listTestCasesFromSearch(queryParams, 10, 0, ADMIN_AUTH_HEADERS); + + assertTrue( + results.getData().stream() + .anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase.getFullyQualifiedName())), + "Test case should be found after table follower update"); + } + @Test void test_entityStatusUpdateAndPatch(TestInfo test) throws IOException { // Create a test case with APPROVED status by default diff --git a/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json b/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json index 20ef1ebbb5d..4b5132d27d1 100644 --- a/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json +++ b/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json @@ -139,6 +139,10 @@ "description": "Domains the test case belongs to. When not set, the test case inherits the domain from the table it belongs to.", "$ref": "../type/entityReferenceList.json" }, + "followers": { + "description": "Followers of this test case. When not set, the test case inherits the followers from the table it belongs to.", + "$ref": "../type/entityReferenceList.json" + }, "useDynamicAssertion": { "description": "If the test definition supports it, use dynamic assertion to evaluate the test case.", "type": "boolean", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts index 12eedb1285c..b9ed818dfe2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts @@ -57,6 +57,11 @@ export interface TestCase { * Sample of failed rows for this test case. */ failedRowsSample?: TableData; + /** + * Followers of this test case. When not set, the test case inherits the followers from the + * table it belongs to. + */ + followers?: EntityReference[]; /** * FullyQualifiedName same as `name`. */