From b172a7462644b0e0d43ee964f5708eba06fa438f Mon Sep 17 00:00:00 2001 From: Teddy Crepineau Date: Fri, 8 Dec 2023 21:00:51 +0100 Subject: [PATCH 1/2] feat: add test case resolution task workflow --- .../ingestion/source/database/sample_data.py | 10 +- .../service/jdbi3/CollectionDAO.java | 13 ++ .../service/jdbi3/FeedRepository.java | 11 +- .../service/jdbi3/TestCaseRepository.java | 104 ++++++++++ .../TestCaseResolutionStatusRepository.java | 112 ++++++++++ .../TestCaseResolutionStatusResource.java | 13 -- .../openmetadata/service/util/EntityUtil.java | 4 + .../dqtests/TestCaseResourceTest.java | 193 +++++++++++++++++- .../resources/feeds/FeedResourceTest.java | 5 + .../json/schema/api/feed/closeTask.json | 4 + .../json/schema/api/feed/resolveTask.json | 8 + .../tests/createTestCaseResolutionStatus.json | 1 - .../json/schema/entity/feed/thread.json | 8 + .../resources/json/schema/tests/inReview.json | 17 -- .../tests/testCaseResolutionStatus.json | 5 +- 15 files changed, 456 insertions(+), 52 deletions(-) delete mode 100644 openmetadata-spec/src/main/resources/json/schema/tests/inReview.json diff --git a/ingestion/src/metadata/ingestion/source/database/sample_data.py b/ingestion/src/metadata/ingestion/source/database/sample_data.py index 5aeecb23a9bc..7824a95e9045 100644 --- a/ingestion/src/metadata/ingestion/source/database/sample_data.py +++ b/ingestion/src/metadata/ingestion/source/database/sample_data.py @@ -1426,7 +1426,10 @@ def ingest_test_case(self) -> Iterable[Either[OMetaTestCaseSample]]: ) create_test_case_resolution.testCaseResolutionStatusDetails = Assigned( assignee=EntityReference( - id=user.id.__root__, type="user" + id=user.id.__root__, + type="user", + name=user.name.__root__, + fullyQualifiedName=user.fullyQualifiedName.__root__, ) ) if resolution["testCaseResolutionStatusType"] == "Resolved": @@ -1435,7 +1438,10 @@ def ingest_test_case(self) -> Iterable[Either[OMetaTestCaseSample]]: ) create_test_case_resolution.testCaseResolutionStatusDetails = Resolved( resolvedBy=EntityReference( - id=user.id.__root__, type="user" + id=user.id.__root__, + type="user", + name=user.name.__root__, + fullyQualifiedName=user.fullyQualifiedName.__root__, ), testCaseFailureReason=random.choice( list(TestCaseFailureReasonType) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 77acc5ebac44..6d32ea3ea79d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -1064,6 +1064,19 @@ int listCountThreadsByOwner( @BindList("teamIds") List teamIds, @Define("condition") String condition); + @ConnectionAwareSqlQuery( + value = + "SELECT json FROM thread_entity " + + "WHERE (json -> '$.task.testCaseResolutionStatusId') = :testCaseResolutionStatusId; ", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT json FROM thread_entity " + + "WHERE (json#>'{task}'->>'testCaseResolutionStatusId') = :testCaseResolutionStatusId; ", + connectionType = POSTGRES) + String fetchThreadByTestCaseResolutionStatusId( + @BindUUID("testCaseResolutionStatusId") UUID testCaseResolutionStatusId); + default List listThreadsByEntityLink( FeedFilter filter, EntityLink entityLink, int limit, int relation, String userName, List teamNames) { int filterRelation = -1; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java index 99c6af8b58ca..3c7ecb97bd0f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java @@ -26,6 +26,7 @@ import static org.openmetadata.service.exception.CatalogExceptionMessage.ANNOUNCEMENT_INVALID_START_TIME; import static org.openmetadata.service.exception.CatalogExceptionMessage.ANNOUNCEMENT_OVERLAP; import static org.openmetadata.service.exception.CatalogExceptionMessage.entityNotFound; +import static org.openmetadata.service.jdbi3.UserRepository.TEAMS_FIELD; import static org.openmetadata.service.util.EntityUtil.compareEntityReference; import static org.openmetadata.service.util.RestUtil.DELETED_TEAM_DISPLAY; import static org.openmetadata.service.util.RestUtil.DELETED_TEAM_NAME; @@ -319,7 +320,7 @@ public PatchResponse resolveTask(UriInfo uriInfo, Thread thread, String return new PatchResponse<>(Status.OK, updatedHref, RestUtil.ENTITY_UPDATED); } - private void resolveTask(ThreadContext threadContext, String user, ResolveTask resolveTask) { + protected void resolveTask(ThreadContext threadContext, String user, ResolveTask resolveTask) { TaskWorkflow taskWorkflow = threadContext.getTaskWorkflow(); EntityInterface aboutEntity = threadContext.getAboutEntity(); String origJson = JsonUtils.pojoToJson(aboutEntity); @@ -568,7 +569,7 @@ public ResultList list(FeedFilter filter, String link, int limitPosts, U total = filteredThreads.getTotalCount(); } else { // Only data assets are added as about - User user = userId != null ? Entity.getEntity(USER, userId, "teams", NON_DELETED) : null; + User user = userId != null ? Entity.getEntity(USER, userId, TEAMS_FIELD, NON_DELETED) : null; List teamNameHash = getTeamNames(user); String userName = user == null ? null : user.getFullyQualifiedName(); List jsons = @@ -710,7 +711,7 @@ public final PatchResponse patchThread(UriInfo uriInfo, UUID id, String public void checkPermissionsForResolveTask(Thread thread, boolean closeTask, SecurityContext securityContext) { String userName = securityContext.getUserPrincipal().getName(); - User user = Entity.getEntityByName(USER, userName, "teams", NON_DELETED); + User user = Entity.getEntityByName(USER, userName, TEAMS_FIELD, NON_DELETED); EntityLink about = EntityLink.parse(thread.getAbout()); EntityReference aboutRef = EntityUtil.validateEntityLink(about); if (Boolean.TRUE.equals(user.getIsAdmin())) { @@ -976,7 +977,7 @@ private FilteredThreads getThreadsByOwner(FeedFilter filter, UUID userId, int li /** Returns the threads where the user or the team they belong to were mentioned by other users with @mention. */ private FilteredThreads getThreadsByMentions(FeedFilter filter, UUID userId, int limit) { - User user = Entity.getEntity(Entity.USER, userId, "teams", NON_DELETED); + User user = Entity.getEntity(Entity.USER, userId, TEAMS_FIELD, NON_DELETED); String userNameHash = getUserNameHash(user); // Return the threads where the user or team was mentioned List teamNamesHash = getTeamNames(user); @@ -998,7 +999,7 @@ private FilteredThreads getThreadsByMentions(FeedFilter filter, UUID userId, int private List getTeamIds(UUID userId) { List teamIds = null; if (userId != null) { - User user = Entity.getEntity(Entity.USER, userId, "teams", NON_DELETED); + User user = Entity.getEntity(Entity.USER, userId, TEAMS_FIELD, NON_DELETED); teamIds = listOrEmpty(user.getTeams()).stream().map(ref -> ref.getId().toString()).collect(Collectors.toList()); } return nullOrEmpty(teamIds) ? List.of(StringUtils.EMPTY) : teamIds; 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 b23a97e877b3..90862a60efe8 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 @@ -4,6 +4,7 @@ import static org.openmetadata.service.Entity.TEST_CASE; import static org.openmetadata.service.Entity.TEST_DEFINITION; import static org.openmetadata.service.Entity.TEST_SUITE; +import static org.openmetadata.service.Entity.getEntityByName; import static org.openmetadata.service.Entity.getEntityReferenceByName; import static org.openmetadata.service.util.RestUtil.ENTITY_NO_CHANGE; import static org.openmetadata.service.util.RestUtil.LOGICAL_TEST_CASES_ADDED; @@ -19,12 +20,17 @@ import javax.ws.rs.core.UriInfo; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.api.feed.CloseTask; +import org.openmetadata.schema.api.feed.ResolveTask; +import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.tests.ResultSummary; import org.openmetadata.schema.tests.TestCase; import org.openmetadata.schema.tests.TestCaseParameter; import org.openmetadata.schema.tests.TestCaseParameterValue; import org.openmetadata.schema.tests.TestDefinition; import org.openmetadata.schema.tests.TestSuite; +import org.openmetadata.schema.tests.type.Assigned; +import org.openmetadata.schema.tests.type.Resolved; import org.openmetadata.schema.tests.type.TestCaseResolutionStatus; import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes; import org.openmetadata.schema.tests.type.TestCaseResult; @@ -36,6 +42,7 @@ import org.openmetadata.schema.type.FieldChange; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.TaskType; import org.openmetadata.schema.utils.EntityInterfaceUtil; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.EntityNotFoundException; @@ -588,6 +595,103 @@ public ResultList listBefore( return getResultList(testCases, beforeCursor, afterCursor, total); } + @Override + public FeedRepository.TaskWorkflow getTaskWorkflow(FeedRepository.ThreadContext threadContext) { + validateTaskThread(threadContext); + TaskType taskType = threadContext.getThread().getTask().getType(); + if (EntityUtil.isTestCaseFailureResolutionTask(taskType)) { + return new TestCaseRepository.TestCaseFailureResolutionTaskWorkflow(threadContext); + } + return super.getTaskWorkflow(threadContext); + } + + public static class TestCaseFailureResolutionTaskWorkflow extends FeedRepository.TaskWorkflow { + TestCaseResolutionStatusRepository testCaseResolutionStatusRepository; + + TestCaseFailureResolutionTaskWorkflow(FeedRepository.ThreadContext threadContext) { + super(threadContext); + this.testCaseResolutionStatusRepository = + (TestCaseResolutionStatusRepository) Entity.getEntityTimeSeriesRepository(Entity.TEST_CASE_RESOLUTION_STATUS); + } + + @Override + @Transaction + public TestCase performTask(String userName, ResolveTask resolveTask) { + + // We need to get the latest test case resolution status to get the state id + TestCaseResolutionStatus latestTestCaseResolutionStatus = + testCaseResolutionStatusRepository.getLatestRecord(resolveTask.getTestCaseFQN()); + + if (latestTestCaseResolutionStatus == null) { + throw new EntityNotFoundException( + String.format("Failed to find test case resolution status for %s", resolveTask.getTestCaseFQN())); + } + User user = getEntityByName(Entity.USER, userName, "", Include.ALL); + TestCaseResolutionStatus testCaseResolutionStatus = + new TestCaseResolutionStatus() + .withId(UUID.randomUUID()) + .withStateId(latestTestCaseResolutionStatus.getStateId()) + .withTimestamp(System.currentTimeMillis()) + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Resolved) + .withTestCaseResolutionStatusDetails( + new Resolved() + .withTestCaseFailureComment(resolveTask.getNewValue()) + .withTestCaseFailureReason(resolveTask.getTestCaseFailureReason()) + .withResolvedBy(user.getEntityReference())) + .withUpdatedAt(System.currentTimeMillis()) + .withTestCaseReference(latestTestCaseResolutionStatus.getTestCaseReference()) + .withUpdatedBy(user.getEntityReference()); + + Entity.getCollectionDAO() + .testCaseResolutionStatusTimeSeriesDao() + .insert( + testCaseResolutionStatus.getTestCaseReference().getFullyQualifiedName(), + Entity.TEST_CASE_RESOLUTION_STATUS, + JsonUtils.pojoToJson(testCaseResolutionStatus)); + testCaseResolutionStatusRepository.postCreate(testCaseResolutionStatus); + return Entity.getEntity(testCaseResolutionStatus.getTestCaseReference(), "", Include.ALL); + } + + @Override + @Transaction + public void closeTask(String userName, CloseTask closeTask) { + // closing task in the context of test case resolution status means that the resolution task has been reassigned + // to someone else + TestCaseResolutionStatus latestTestCaseResolutionStatus = + testCaseResolutionStatusRepository.getLatestRecord(closeTask.getTestCaseFQN()); + if (latestTestCaseResolutionStatus == null) { + return; + } + + if (latestTestCaseResolutionStatus + .getTestCaseResolutionStatusType() + .equals(TestCaseResolutionStatusTypes.Resolved)) { + // if the test case is already resolved then we'll return. We don't need to update the state + return; + } + + User user = Entity.getEntityByName(Entity.USER, userName, "", Include.ALL); + User assignee = Entity.getEntityByName(Entity.USER, closeTask.getComment(), "", Include.ALL); + TestCaseResolutionStatus testCaseResolutionStatus = + new TestCaseResolutionStatus() + .withId(UUID.randomUUID()) + .withStateId(latestTestCaseResolutionStatus.getStateId()) + .withTimestamp(System.currentTimeMillis()) + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Assigned) + .withTestCaseResolutionStatusDetails(new Assigned().withAssignee(assignee.getEntityReference())) + .withUpdatedAt(System.currentTimeMillis()) + .withTestCaseReference(latestTestCaseResolutionStatus.getTestCaseReference()) + .withUpdatedBy(user.getEntityReference()); + Entity.getCollectionDAO() + .testCaseResolutionStatusTimeSeriesDao() + .insert( + testCaseResolutionStatus.getTestCaseReference().getFullyQualifiedName(), + Entity.TEST_CASE_RESOLUTION_STATUS, + JsonUtils.pojoToJson(testCaseResolutionStatus)); + testCaseResolutionStatusRepository.postCreate(testCaseResolutionStatus); + } + } + public class TestUpdater extends EntityUpdater { public TestUpdater(TestCase original, TestCase updated, Operation operation) { super(original, updated, operation); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java index ffef538b5f29..46215c1b8700 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java @@ -5,13 +5,30 @@ import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.InvocationTargetException; +import java.util.Collections; import java.util.List; import java.util.UUID; import javax.json.JsonPatch; import javax.ws.rs.core.Response; +import org.jdbi.v3.sqlobject.transaction.Transaction; +import org.openmetadata.schema.api.feed.CloseTask; +import org.openmetadata.schema.api.feed.ResolveTask; +import org.openmetadata.schema.entity.feed.Thread; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.tests.TestCase; +import org.openmetadata.schema.tests.type.Assigned; +import org.openmetadata.schema.tests.type.Resolved; import org.openmetadata.schema.tests.type.TestCaseResolutionStatus; +import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.TaskDetails; +import org.openmetadata.schema.type.TaskStatus; +import org.openmetadata.schema.type.TaskType; +import org.openmetadata.schema.type.ThreadType; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.EntityNotFoundException; +import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.RestUtil; @@ -73,4 +90,99 @@ private void validatePatchFields(TestCaseResolutionStatus updated, TestCaseResol } } } + + @Override + protected void postCreate(TestCaseResolutionStatus entity) { + super.postCreate(entity); + if (entity.getTestCaseResolutionStatusType() == TestCaseResolutionStatusTypes.Assigned) { + createAssignedTask(entity); + } + } + + @Override + @Transaction + public TestCaseResolutionStatus createNewRecord( + TestCaseResolutionStatus recordEntity, String extension, String recordFQN) { + TestCaseResolutionStatus latestTestCaseFailure = + getLatestRecord(recordEntity.getTestCaseReference().getFullyQualifiedName()); + recordEntity.setStateId( + ((latestTestCaseFailure != null) + && (latestTestCaseFailure.getTestCaseResolutionStatusType() != TestCaseResolutionStatusTypes.Resolved)) + ? latestTestCaseFailure.getStateId() + : UUID.randomUUID()); + + if (latestTestCaseFailure != null + && latestTestCaseFailure.getTestCaseResolutionStatusType().equals(TestCaseResolutionStatusTypes.Assigned)) { + String jsonThread = + Entity.getCollectionDAO().feedDAO().fetchThreadByTestCaseResolutionStatusId(latestTestCaseFailure.getId()); + Thread thread = JsonUtils.readValue(jsonThread, Thread.class); + if (recordEntity.getTestCaseResolutionStatusType().equals(TestCaseResolutionStatusTypes.Assigned)) { + // We have an open task and we are passing an assigned status type (i.e. we are re-assigning). This scenario is + // when the test case resolution status + // is being sent through the API (and not resolving an open task) + // we'll get the associated thread with the latest test case failure + + // we'll close the task (the flow will also create a new assigned test case resolution status and open a new + // task) + Assigned assigned = JsonUtils.convertValue(recordEntity.getTestCaseResolutionStatusDetails(), Assigned.class); + User assignee = Entity.getEntity(Entity.USER, assigned.getAssignee().getId(), "", Include.ALL); + User updatedBy = Entity.getEntity(Entity.USER, recordEntity.getUpdatedBy().getId(), "", Include.ALL); + CloseTask closeTask = + new CloseTask() + .withComment(assignee.getFullyQualifiedName()) + .withTestCaseFQN(recordEntity.getTestCaseReference().getFullyQualifiedName()); + Entity.getFeedRepository().closeTask(thread, updatedBy.getFullyQualifiedName(), closeTask); + return getLatestRecord(recordEntity.getTestCaseReference().getFullyQualifiedName()); + } else if (recordEntity.getTestCaseResolutionStatusType().equals(TestCaseResolutionStatusTypes.Resolved)) { + // We have an open task and we are passing a resolved status type (i.e. we are marking it as resolved). This + // scenario is when the test case resolution status + // is being sent through the API (and not resolving an open task) + Resolved resolved = JsonUtils.convertValue(recordEntity.getTestCaseResolutionStatusDetails(), Resolved.class); + TestCase testCase = + Entity.getEntity(Entity.TEST_CASE, recordEntity.getTestCaseReference().getId(), "", Include.ALL); + User updatedBy = Entity.getEntity(Entity.USER, recordEntity.getUpdatedBy().getId(), "", Include.ALL); + ResolveTask resolveTask = + new ResolveTask() + .withTestCaseFQN(testCase.getFullyQualifiedName()) + .withTestCaseFailureReason(resolved.getTestCaseFailureReason()) + .withNewValue(resolved.getTestCaseFailureComment()); + Entity.getFeedRepository() + .resolveTask(new FeedRepository.ThreadContext(thread), updatedBy.getFullyQualifiedName(), resolveTask); + return getLatestRecord(testCase.getFullyQualifiedName()); + } + + throw new IllegalArgumentException( + String.format( + "Test Case Resolution status %s with type `Assigned` cannot be moved to `New` or `Ack`. You can `Assign` or `Resolve` the test case failure. ", + latestTestCaseFailure.getId().toString())); + } + return super.createNewRecord(recordEntity, extension, recordFQN); + } + + private void createAssignedTask(TestCaseResolutionStatus entity) { + Assigned assigned = JsonUtils.convertValue(entity.getTestCaseResolutionStatusDetails(), Assigned.class); + List assignees = Collections.singletonList(assigned.getAssignee()); + TaskDetails taskDetails = + new TaskDetails() + .withAssignees(assignees) + .withType(TaskType.RequestTestCaseFailureResolution) + .withStatus(TaskStatus.Open) + .withTestCaseResolutionStatusId(entity.getId()); + + MessageParser.EntityLink entityLink = + new MessageParser.EntityLink(Entity.TEST_CASE, entity.getTestCaseReference().getFullyQualifiedName()); + Thread thread = + new Thread() + .withId(UUID.randomUUID()) + .withThreadTs(System.currentTimeMillis()) + .withMessage("Test Case Failure Resolution requested for ") + .withCreatedBy(entity.getUpdatedBy().getName()) + .withAbout(entityLink.getLinkString()) + .withType(ThreadType.Task) + .withTask(taskDetails) + .withUpdatedBy(entity.getUpdatedBy().getName()) + .withUpdatedAt(System.currentTimeMillis()); + FeedRepository feedRepository = Entity.getFeedRepository(); + feedRepository.create(thread); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java index 3788ac3a1641..ba853236f0f1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java @@ -257,21 +257,8 @@ public Response patch( private TestCaseResolutionStatus getTestCaseResolutionStatus( TestCase testCaseEntity, CreateTestCaseResolutionStatus createTestCaseResolutionStatus, String userName) { User userEntity = Entity.getEntityByName(Entity.USER, userName, null, Include.ALL); - TestCaseResolutionStatus latestTestCaseFailure = repository.getLatestRecord(testCaseEntity.getFullyQualifiedName()); - UUID stateId; - - if ((latestTestCaseFailure != null) - && (latestTestCaseFailure.getTestCaseResolutionStatusType() != TestCaseResolutionStatusTypes.Resolved)) { - // if the latest status is not resolved then use the same sequence id - stateId = latestTestCaseFailure.getStateId(); - } else { - // if the latest status is resolved then create a new sequence id - // effectively creating a new test case failure status sequence - stateId = UUID.randomUUID(); - } return new TestCaseResolutionStatus() - .withStateId(stateId) .withTimestamp(System.currentTimeMillis()) .withTestCaseResolutionStatusType(createTestCaseResolutionStatus.getTestCaseResolutionStatusType()) .withTestCaseResolutionStatusDetails(createTestCaseResolutionStatus.getTestCaseResolutionStatusDetails()) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityUtil.java index c3b63aa660a5..8d88dd8d93b9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityUtil.java @@ -563,6 +563,10 @@ public static boolean isApprovalTask(TaskType taskType) { return taskType == TaskType.RequestApproval; } + public static boolean isTestCaseFailureResolutionTask(TaskType taskType) { + return taskType == TaskType.RequestTestCaseFailureResolution; + } + public static Column findColumn(List columns, String columnName) { return columns.stream() .filter(c -> c.getName().equals(columnName)) 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 7e520878256b..c2c1925a6dd8 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 @@ -46,10 +46,13 @@ import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestMethodOrder; import org.openmetadata.schema.api.data.CreateTable; +import org.openmetadata.schema.api.feed.CloseTask; +import org.openmetadata.schema.api.feed.ResolveTask; import org.openmetadata.schema.api.tests.CreateTestCase; import org.openmetadata.schema.api.tests.CreateTestCaseResolutionStatus; import org.openmetadata.schema.api.tests.CreateTestSuite; import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.feed.Thread; import org.openmetadata.schema.tests.ResultSummary; import org.openmetadata.schema.tests.TestCase; import org.openmetadata.schema.tests.TestCaseParameterValue; @@ -66,9 +69,11 @@ import org.openmetadata.schema.type.ChangeDescription; import org.openmetadata.schema.type.Column; import org.openmetadata.schema.type.ColumnDataType; +import org.openmetadata.schema.type.TaskStatus; import org.openmetadata.service.Entity; import org.openmetadata.service.resources.EntityResourceTest; import org.openmetadata.service.resources.databases.TableResourceTest; +import org.openmetadata.service.resources.feeds.FeedResourceTest; import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.ResultList; import org.openmetadata.service.util.TestUtils; @@ -1090,8 +1095,8 @@ void test_listTestCaseFailureStatusPagination(TestInfo test) throws IOException for (int i = 0; i < maxEntities; i++) { // randomly pick a status type - int rnd = new Random().nextInt(testCaseFailureStatusTypes.length); - TestCaseResolutionStatusTypes testCaseFailureStatusType = testCaseFailureStatusTypes[rnd]; + TestCaseResolutionStatusTypes testCaseFailureStatusType = + testCaseFailureStatusTypes[i % testCaseFailureStatusTypes.length]; CreateTestCaseResolutionStatus createTestCaseFailureStatus = new CreateTestCaseResolutionStatus() @@ -1115,7 +1120,8 @@ void test_listTestCaseFailureStatusPagination(TestInfo test) throws IOException Long endTs = System.currentTimeMillis() + 1000; // List all entities and use it for checking pagination - ResultList allEntities = getTestCaseFailureStatus(1000000, null, false, startTs, endTs); + ResultList allEntities = + getTestCaseFailureStatus(1000000, null, false, startTs, endTs, null); paginateTestCaseFailureStatus(maxEntities, allEntities, null, startTs, endTs); } @@ -1134,11 +1140,11 @@ void test_listTestCaseFailureStatusLatestPagination(TestInfo test) throws IOExce // create `maxEntities` number of test cases testCaseEntity = createEntity(createRequest(getEntityName(test) + i), ADMIN_AUTH_HEADERS); - for (int j = 0; j < 6; j++) { + for (int j = 0; j < 5; j++) { // create 5 test case failure statuses for each test case // randomly pick a status type - int rnd = new Random().nextInt(testCaseFailureStatusTypes.length); - TestCaseResolutionStatusTypes testCaseFailureStatusType = testCaseFailureStatusTypes[rnd]; + TestCaseResolutionStatusTypes testCaseFailureStatusType = + testCaseFailureStatusTypes[j % TestCaseResolutionStatusTypes.values().length]; CreateTestCaseResolutionStatus createTestCaseFailureStatus = new CreateTestCaseResolutionStatus() @@ -1163,7 +1169,8 @@ void test_listTestCaseFailureStatusLatestPagination(TestInfo test) throws IOExce Long endTs = System.currentTimeMillis() + 1000; // List all entities and use it for checking pagination - ResultList allEntities = getTestCaseFailureStatus(1000000, null, true, startTs, endTs); + ResultList allEntities = + getTestCaseFailureStatus(1000000, null, true, startTs, endTs, null); paginateTestCaseFailureStatus(maxEntities, allEntities, true, startTs, endTs); } @@ -1220,6 +1227,168 @@ void patch_TestCaseResultFailureUnauthorizedFields(TestInfo test) throws HttpRes "Field testCaseResolutionStatusType is not allowed to be updated"); } + @Test + public void test_testCaseResolutionTaskResolveWorkflowThruFeed(TestInfo test) throws HttpResponseException { + Long startTs = System.currentTimeMillis(); + FeedResourceTest feedResourceTest = new FeedResourceTest(); + + TestCase testCaseEntity = createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS); + CreateTestCaseResolutionStatus createTestCaseFailureStatus = + new CreateTestCaseResolutionStatus() + .withTestCaseReference(testCaseEntity.getFullyQualifiedName()) + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Assigned) + .withTestCaseResolutionStatusDetails(new Assigned().withAssignee(USER1_REF)); + TestCaseResolutionStatus testCaseFailureStatus = createTestCaseFailureStatus(createTestCaseFailureStatus); + String jsonThread = + Entity.getCollectionDAO().feedDAO().fetchThreadByTestCaseResolutionStatusId(testCaseFailureStatus.getId()); + Thread thread = JsonUtils.readValue(jsonThread, Thread.class); + assertEquals(testCaseFailureStatus.getId(), thread.getTask().getTestCaseResolutionStatusId()); + assertEquals(TaskStatus.Open, thread.getTask().getStatus()); + + // resolve the task. The old task should be closed and the latest test case resolution status + // should be updated (resolved) with the same state ID + + ResolveTask resolveTask = + new ResolveTask() + .withTestCaseFQN(testCaseEntity.getFullyQualifiedName()) + .withTestCaseFailureReason(TestCaseFailureReasonType.FalsePositive) + .withNewValue("False positive, test case was valid"); + feedResourceTest.resolveTask(thread.getTask().getId(), resolveTask, ADMIN_AUTH_HEADERS); + jsonThread = + Entity.getCollectionDAO().feedDAO().fetchThreadByTestCaseResolutionStatusId(testCaseFailureStatus.getId()); + thread = JsonUtils.readValue(jsonThread, Thread.class); + // Confirm that the task is closed + assertEquals(TaskStatus.Closed, thread.getTask().getStatus()); + + // We'll confirm that we have created a new test case resolution status with the same state ID and type Resolved + ResultList mostRecentTestCaseResolutionStatus = + getTestCaseFailureStatus( + 10, null, true, startTs, System.currentTimeMillis(), testCaseEntity.getFullyQualifiedName()); + assertEquals(1, mostRecentTestCaseResolutionStatus.getData().size()); + TestCaseResolutionStatus mostRecentTestCaseResolutionStatusData = + mostRecentTestCaseResolutionStatus.getData().get(0); + assertEquals( + TestCaseResolutionStatusTypes.Resolved, + mostRecentTestCaseResolutionStatusData.getTestCaseResolutionStatusType()); + assertEquals(testCaseFailureStatus.getStateId(), mostRecentTestCaseResolutionStatusData.getStateId()); + Resolved resolved = + JsonUtils.convertValue( + mostRecentTestCaseResolutionStatusData.getTestCaseResolutionStatusDetails(), Resolved.class); + assertEquals(TestCaseFailureReasonType.FalsePositive, resolved.getTestCaseFailureReason()); + assertEquals("False positive, test case was valid", resolved.getTestCaseFailureComment()); + } + + @Test + public void test_testCaseResolutionTaskCloseWorkflowThruFeed(TestInfo test) throws HttpResponseException { + Long startTs = System.currentTimeMillis(); + FeedResourceTest feedResourceTest = new FeedResourceTest(); + + TestCase testCaseEntity = createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS); + CreateTestCaseResolutionStatus createTestCaseFailureStatus = + new CreateTestCaseResolutionStatus() + .withTestCaseReference(testCaseEntity.getFullyQualifiedName()) + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Assigned) + .withTestCaseResolutionStatusDetails(new Assigned().withAssignee(USER1_REF)); + TestCaseResolutionStatus testCaseFailureStatus = createTestCaseFailureStatus(createTestCaseFailureStatus); + + // Assert that the task is open + String jsonThread = + Entity.getCollectionDAO().feedDAO().fetchThreadByTestCaseResolutionStatusId(testCaseFailureStatus.getId()); + Thread thread = JsonUtils.readValue(jsonThread, Thread.class); + assertEquals(testCaseFailureStatus.getId(), thread.getTask().getTestCaseResolutionStatusId()); + assertEquals(TaskStatus.Open, thread.getTask().getStatus()); + + // close the task. The old task should be closed and the latest test case resolution status + // should be updated (assigned) with the same state ID and a new task should be opened + + CloseTask closeTask = + new CloseTask() + .withComment(USER1.getFullyQualifiedName()) + .withTestCaseFQN(testCaseEntity.getFullyQualifiedName()); + feedResourceTest.closeTask(thread.getTask().getId(), closeTask, ADMIN_AUTH_HEADERS); + jsonThread = + Entity.getCollectionDAO().feedDAO().fetchThreadByTestCaseResolutionStatusId(testCaseFailureStatus.getId()); + thread = JsonUtils.readValue(jsonThread, Thread.class); + assertEquals(TaskStatus.Closed, thread.getTask().getStatus()); + + // We'll confirm that we have created a new test case resolution status with the same state ID and type Assigned + ResultList mostRecentTestCaseResolutionStatus = + getTestCaseFailureStatus( + 10, null, true, startTs, System.currentTimeMillis(), testCaseEntity.getFullyQualifiedName()); + assertEquals(1, mostRecentTestCaseResolutionStatus.getData().size()); + TestCaseResolutionStatus mostRecentTestCaseResolutionStatusData = + mostRecentTestCaseResolutionStatus.getData().get(0); + assertEquals( + TestCaseResolutionStatusTypes.Assigned, + mostRecentTestCaseResolutionStatusData.getTestCaseResolutionStatusType()); + assertEquals(testCaseFailureStatus.getStateId(), mostRecentTestCaseResolutionStatusData.getStateId()); + Assigned assigned = + JsonUtils.convertValue( + mostRecentTestCaseResolutionStatusData.getTestCaseResolutionStatusDetails(), Assigned.class); + assertEquals(USER1.getFullyQualifiedName(), assigned.getAssignee().getFullyQualifiedName()); + } + + @Test + public void test_testCaseResolutionTaskWorkflowThruAPI(TestInfo test) throws HttpResponseException { + TestCase testCaseEntity = createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS); + + CreateTestCaseResolutionStatus createTestCaseFailureStatus = + new CreateTestCaseResolutionStatus() + .withTestCaseReference(testCaseEntity.getFullyQualifiedName()) + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.New) + .withTestCaseResolutionStatusDetails(null); + + createTestCaseFailureStatus(createTestCaseFailureStatus); + TestCaseResolutionStatus testCaseFailureStatusAssigned = + createTestCaseFailureStatus( + createTestCaseFailureStatus + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Assigned) + .withTestCaseResolutionStatusDetails(new Assigned().withAssignee(USER1_REF))); + + // Confirm that the task is open + String jsonThread = + Entity.getCollectionDAO() + .feedDAO() + .fetchThreadByTestCaseResolutionStatusId(testCaseFailureStatusAssigned.getId()); + Thread thread = JsonUtils.readValue(jsonThread, Thread.class); + assertEquals(TaskStatus.Open, thread.getTask().getStatus()); + assertEquals(testCaseFailureStatusAssigned.getId(), thread.getTask().getTestCaseResolutionStatusId()); + + // Create a new test case resolution status with type Resolved + // and confirm the task is closed + CreateTestCaseResolutionStatus createTestCaseFailureStatusResolved = + createTestCaseFailureStatus + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Resolved) + .withTestCaseResolutionStatusDetails( + new Resolved() + .withTestCaseFailureComment("resolved") + .withTestCaseFailureReason(TestCaseFailureReasonType.MissingData) + .withResolvedBy(USER1_REF)); + createTestCaseFailureStatus(createTestCaseFailureStatusResolved); + + jsonThread = Entity.getCollectionDAO().feedDAO().findById(thread.getId()); + thread = JsonUtils.readValue(jsonThread, Thread.class); + assertEquals(TaskStatus.Closed, thread.getTask().getStatus()); + } + + @Test + public void unauthorizedTestCaseResolutionFlow(TestInfo test) throws HttpResponseException { + TestCase testCaseEntity = createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS); + CreateTestCaseResolutionStatus createTestCaseFailureStatus = + new CreateTestCaseResolutionStatus() + .withTestCaseReference(testCaseEntity.getFullyQualifiedName()) + .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Assigned) + .withTestCaseResolutionStatusDetails(new Assigned().withAssignee(USER1_REF)); + createTestCaseFailureStatus(createTestCaseFailureStatus); + + assertResponseContains( + () -> + createTestCaseFailureStatus( + createTestCaseFailureStatus.withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.Ack)), + BAD_REQUEST, + "with type `Assigned` cannot be moved to `New` or `Ack`. You can `Assign` or `Resolve` the test case failure."); + } + public void deleteTestCaseResult(String fqn, Long timestamp, Map authHeaders) throws HttpResponseException { WebTarget target = getCollection().path("/" + fqn + "/testCaseResult/" + timestamp); @@ -1475,11 +1644,13 @@ private ResultList getTestCaseFailureStatusByStateId(U } private ResultList getTestCaseFailureStatus( - int limit, String offset, Boolean latest, Long startTs, Long endTs) throws HttpResponseException { + int limit, String offset, Boolean latest, Long startTs, Long endTs, String testCaseFqn) + throws HttpResponseException { WebTarget target = getCollection().path("/testCaseResolutionStatus"); target = target.queryParam("limit", limit); target = offset != null ? target.queryParam("offset", offset) : target; target = latest != null ? target.queryParam("latest", latest) : target.queryParam("latest", false); + target = testCaseFqn != null ? target.queryParam("entityFQNHash", testCaseFqn) : target; target = startTs != null @@ -1534,7 +1705,7 @@ private void paginateTestCaseFailureStatus( ResultList forwardPage; ResultList backwardPage; do { // For each limit (or page size) - forward scroll till the end - forwardPage = getTestCaseFailureStatus(limit, after, latest, startTs, endTs); + forwardPage = getTestCaseFailureStatus(limit, after, latest, startTs, endTs, null); after = forwardPage.getPaging().getAfter(); before = forwardPage.getPaging().getBefore(); assertEntityPagination(allEntities.getData(), forwardPage, limit, indexInAllTables); @@ -1543,7 +1714,7 @@ private void paginateTestCaseFailureStatus( assertNull(before); } else { // Make sure scrolling back based on before cursor returns the correct result - backwardPage = getTestCaseFailureStatus(limit, before, latest, startTs, endTs); + backwardPage = getTestCaseFailureStatus(limit, before, latest, startTs, endTs, null); assertEntityPagination(allEntities.getData(), backwardPage, limit, (indexInAllTables - limit)); } @@ -1555,7 +1726,7 @@ private void paginateTestCaseFailureStatus( pageCount = 0; indexInAllTables = totalRecords - limit - forwardPage.getData().size(); do { - forwardPage = getTestCaseFailureStatus(limit, before, latest, startTs, endTs); + forwardPage = getTestCaseFailureStatus(limit, before, latest, startTs, endTs, null); before = forwardPage.getPaging().getBefore(); assertEntityPagination(allEntities.getData(), forwardPage, limit, indexInAllTables); pageCount++; diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/FeedResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/FeedResourceTest.java index a7bc3f857884..6922b44095cb 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/FeedResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/FeedResourceTest.java @@ -1337,6 +1337,11 @@ public void closeTask(int id, String comment, Map authHeaders) t TestUtils.put(target, new CloseTask().withComment(comment), Status.OK, authHeaders); } + public void closeTask(int id, CloseTask closeTask, Map authHeaders) throws HttpResponseException { + WebTarget target = getResource("feed/tasks/" + id + "/close"); + TestUtils.put(target, closeTask, Status.OK, authHeaders); + } + public ThreadList listTasks( String entityLink, String userId, diff --git a/openmetadata-spec/src/main/resources/json/schema/api/feed/closeTask.json b/openmetadata-spec/src/main/resources/json/schema/api/feed/closeTask.json index 87cf0c587099..35ad5c2e3f38 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/feed/closeTask.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/feed/closeTask.json @@ -8,6 +8,10 @@ "comment": { "description": "The closing comment explaining why the task is being closed.", "type": "string" + }, + "testCaseFQN": { + "description": "Fully qualified name of the test case.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" } }, "required": ["comment"], diff --git a/openmetadata-spec/src/main/resources/json/schema/api/feed/resolveTask.json b/openmetadata-spec/src/main/resources/json/schema/api/feed/resolveTask.json index 2a72f80a16ba..3cfb9fc32301 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/feed/resolveTask.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/feed/resolveTask.json @@ -8,6 +8,14 @@ "newValue": { "description": "The new value object that needs to be updated to resolve the task.", "type": "string" + }, + "testCaseFailureReason": { + "description": "Reason of Test Case resolution.", + "$ref": "../../tests/resolved.json#/definitions/testCaseFailureReasonType" + }, + "testCaseFQN": { + "description": "Fully qualified name of the test case.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" } }, "required": ["newValue"], diff --git a/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResolutionStatus.json b/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResolutionStatus.json index b6aaf6b908a0..f8444fb4668c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResolutionStatus.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResolutionStatus.json @@ -14,7 +14,6 @@ "description": "Details of the test case failure status.", "oneOf": [ {"$ref": "../../tests/resolved.json"}, - {"$ref": "../../tests/inReview.json"}, {"$ref": "../../tests/assigned.json"} ], "default": null diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/feed/thread.json b/openmetadata-spec/src/main/resources/json/schema/entity/feed/thread.json index 67b596454143..4bef6569d3d6 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/feed/thread.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/feed/thread.json @@ -16,6 +16,7 @@ "RequestTag", "UpdateTag", "RequestApproval", + "RequestTestCaseFailureResolution", "Generic" ], "javaEnums": [ @@ -34,6 +35,9 @@ { "name": "RequestApproval" }, + { + "name": "RequestTestCaseFailureResolution" + }, { "name": "Generic" } @@ -77,6 +81,10 @@ "newValue": { "description": "The new value object that was accepted to complete the task.", "type": "string" + }, + "testCaseResolutionStatusId": { + "description": "The test case resolution status id for which the task is created.", + "$ref": "../../type/basic.json#/definitions/uuid" } }, "required": ["id", "assignees", "type"], diff --git a/openmetadata-spec/src/main/resources/json/schema/tests/inReview.json b/openmetadata-spec/src/main/resources/json/schema/tests/inReview.json deleted file mode 100644 index dd688ec77c27..000000000000 --- a/openmetadata-spec/src/main/resources/json/schema/tests/inReview.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$id": "https://open-metadata.org/schema/tests/inReview.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "InReview", - "description": "tes case failure details for assigned failures", - "javaType": "org.openmetadata.schema.tests.type.InReview", - "type": "object", - "properties": { - "reviewer": { - "description": "User reviewing the resolution implemented by the assignee.", - "$ref": "../type/entityReference.json", - "default": null - } - }, - "required": ["reviewer"], - "additionalProperties": false -} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/tests/testCaseResolutionStatus.json b/openmetadata-spec/src/main/resources/json/schema/tests/testCaseResolutionStatus.json index 0031954fd33b..15121abb1176 100644 --- a/openmetadata-spec/src/main/resources/json/schema/tests/testCaseResolutionStatus.json +++ b/openmetadata-spec/src/main/resources/json/schema/tests/testCaseResolutionStatus.json @@ -11,11 +11,11 @@ "description": "Test case resolution status type.", "javaType": "org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes", "type": "string", - "enum": ["Ack", "Assigned", "New", "Resolved"], + "enum": ["New","Ack", "Assigned", "Resolved"], "javaEnums": [ + {"name": "New"}, {"name": "Ack"}, {"name": "Assigned"}, - {"name": "New"}, {"name": "Resolved"} ] }, @@ -54,7 +54,6 @@ "description": "Details of the test case failure status.", "oneOf": [ {"$ref": "./assigned.json"}, - {"$ref": "./inReview.json"}, {"$ref": "./resolved.json"} ], "default": null From f795e8d2f2edca3ff2aaaae59d90684c9020b7cc Mon Sep 17 00:00:00 2001 From: Teddy Crepineau Date: Tue, 12 Dec 2023 08:30:56 +0100 Subject: [PATCH 2/2] fix: conflict with main --- .../service/resources/dqtests/TestCaseResourceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a4ca0da8c7f2..c855695269a0 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 @@ -1644,7 +1644,8 @@ private ResultList getTestCaseFailureStatusByStateId(U } private ResultList getTestCaseFailureStatus( - int limit, String offset, Boolean latest, Long startTs, Long endTs) throws HttpResponseException { + int limit, String offset, Boolean latest, Long startTs, Long endTs, String testCaseFqn) + throws HttpResponseException { WebTarget target = getCollection().path("/testCaseIncidentStatus"); target = target.queryParam("limit", limit); target = offset != null ? target.queryParam("offset", offset) : target;