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] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
IceS2 2025-09-22 21:38:49 +02:00 committed by GitHub
parent 49bdf1a112
commit a630ca2be6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 525 additions and 85 deletions

View File

@ -1491,6 +1491,8 @@ public abstract class EntityRepository<T extends EntityInterface> {
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<T extends EntityInterface> {
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<T extends EntityInterface> {
}
}
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));

View File

@ -366,12 +366,15 @@ public class TestCaseRepository extends EntityRepository<TestCase> {
// 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);
}
}

View File

@ -95,9 +95,9 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
private final TestCaseMapper mapper = new TestCaseMapper();
private final TestCaseResultMapper testCaseResultMapper = new TestCaseResultMapper();
static final String FIELDS =
"owners,reviewers,entityStatus,testSuite,testDefinition,testSuites,incidentId,domains,tags";
"owners,reviewers,entityStatus,testSuite,testDefinition,testSuites,incidentId,domains,tags,followers";
static final String SEARCH_FIELDS_EXCLUDE =
"testPlatforms,table,database,databaseSchema,service,testSuite,dataQualityDimension,testCaseType,originEntityFQN";
"testPlatforms,table,database,databaseSchema,service,testSuite,dataQualityDimension,testCaseType,originEntityFQN,followers";
@Override
public TestCase addHref(UriInfo uriInfo, TestCase test) {
@ -416,81 +416,52 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
description = "Filter test cases by the user who created them",
schema = @Schema(type = "string"))
@QueryParam("createdBy")
String createdBy)
String createdBy,
@Parameter(
description = "Filter test cases by entities followed by a user",
schema = @Schema(type = "string"))
@QueryParam("followedBy")
String followedBy)
throws IOException {
if ((startTimestamp == null && endTimestamp != null)
|| (startTimestamp != null && endTimestamp == null)) {
throw new IllegalArgumentException("startTimestamp and endTimestamp must be used together");
}
// Validate parameters
validateTimestamps(startTimestamp, endTimestamp);
// Build search filters
SearchSortFilter searchSortFilter =
new SearchSortFilter(sortField, sortType, sortNestedPath, sortNestedMode);
SearchListFilter searchListFilter = new SearchListFilter(include);
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);
if (!nullOrEmpty(owner)) {
EntityInterface entity;
StringBuilder owners = new StringBuilder();
try {
User user = Entity.getEntityByName(Entity.USER, owner, "teams", ALL);
owners.append(user.getId().toString());
if (!nullOrEmpty(user.getTeams())) {
owners
.append(",")
.append(
user.getTeams().stream()
.map(t -> 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<TestCase> 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<TestCase, TestCaseRepositor
}
return resourceContext;
}
private static String resolveUserOrTeamIds(String userOrTeamName, boolean includeTeamMembers) {
if (nullOrEmpty(userOrTeamName)) {
return null;
}
StringBuilder ids = new StringBuilder();
try {
// Try to resolve as a user first
User user =
Entity.getEntityByName(
Entity.USER, userOrTeamName, includeTeamMembers ? "teams" : "", ALL);
ids.append(user.getId().toString());
// If includeTeamMembers is true and user has teams, add team IDs
if (includeTeamMembers && !nullOrEmpty(user.getTeams())) {
ids.append(",")
.append(
user.getTeams().stream()
.map(t -> 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<TestCase> 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<TestCase> tests =
super.listInternalFromSearch(
uriInfo,
securityContext,
fields,
searchListFilter,
limit,
offset,
searchSortFilter,
q,
queryString,
operationContext,
resourceContextInterface);
return PIIMasker.getTestCases(tests, authorizer, securityContext);
}
}

View File

@ -231,6 +231,53 @@ public interface SearchClient<T> {
}
""";
// 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) {

View File

@ -21,6 +21,18 @@ public class SearchListFilter extends Filter<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
}
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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<SearchListFilter> {
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) {

View File

@ -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<EntityReference> 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<EntityReference> 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<EntityReference> copyWithInheritedFlag(List<EntityReference> references) {
if (references == null || references.isEmpty()) {
return new ArrayList<>();
}
List<EntityReference> inheritedReferences =
JsonUtils.deepCopyList(references, EntityReference.class);
inheritedReferences.forEach(ref -> ref.setInherited(true));
return inheritedReferences;
}
}

View File

@ -264,7 +264,7 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
private final String allFields;
private final String
systemEntityName; // System entity provided by the system that can't be deleted
protected final boolean supportsFollowers;
protected boolean supportsFollowers;
protected final boolean supportsVotes;
protected boolean supportsOwners;
protected boolean supportsTags;
@ -4160,6 +4160,45 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
return responseMap.get();
}
/**
* Wait for a specific field to have an expected value in the search index.
* This is useful for waiting for inherited fields to propagate.
*
* @param entityId The entity ID to check
* @param entityType The entity type
* @param fieldName The field name to check
* @param expectedValue The expected value of the field
*/
public static void waitForFieldInSearchIndex(
UUID entityId, String entityType, String fieldName, Object expectedValue) {
Awaitility.await(String.format("Wait for field '%s' to be updated in search index", fieldName))
.ignoreExceptions()
.pollInterval(Duration.ofMillis(500))
.atMost(Duration.ofSeconds(30))
.until(
() -> {
Map<String, Object> 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<String, Object> getEntityDocumentFromSearch(UUID entityId, String entityType)
throws HttpResponseException {
IndexMapping indexMapping = Entity.getSearchRepository().getIndexMapping(entityType);

View File

@ -195,6 +195,8 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
"dataQuality/testCases",
TestCaseResource.FIELDS);
supportsTags = false; // Test cases do not support setting tags directly (inherits from Entity)
supportsFollowers =
false; // Test cases do not support setting followers directly (inherits from parent table)
testCaseResultsCollectionName = "dataQuality/testCases/testCaseResults";
supportsEtag = false;
}
@ -3155,6 +3157,21 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
return TestUtils.get(target, TestCaseResultResource.TestCaseResultList.class, authHeader);
}
public ResultList<TestCase> listTestCasesFromSearch(
Map<String, String> queryParams,
Integer limit,
Integer offset,
Map<String, String> authHeader)
throws HttpResponseException {
WebTarget target = getCollection().path("/search/list");
for (Map.Entry<String, String> 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<String, String> 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<TestCase, CreateTes
assertNull(nullValueResult.getFailedRows(), "Failed rows should be null when not set");
}
@Test
void test_testCaseFollowerInheritance(TestInfo testInfo)
throws IOException, InterruptedException {
// Create a table with a follower
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);
// Add USER1 as follower to the table
tableResourceTest.addFollower(table.getId(), USER1_REF.getId(), OK, 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 search index sync
Map<String, Object> sourceAsMap =
waitForSyncAndGetFromSearchIndex(
testCase.getUpdatedAt(), testCase.getId(), Entity.TEST_CASE);
// Verify the test case inherited the follower from the table
List<String> followers = (List<String>) 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<String, String> queryParams = new HashMap<>();
queryParams.put("followedBy", USER1.getName());
ResultList<TestCase> 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<String> 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<String, String> queryParams = new HashMap<>();
queryParams.put("followedBy", USER1.getName());
ResultList<TestCase> 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

View File

@ -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",

View File

@ -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`.
*/