diff --git a/lib/taskana-core-test/src/test/java/acceptance/task/requestreview/RequestReviewAccTest.java b/lib/taskana-core-test/src/test/java/acceptance/task/requestreview/RequestReviewAccTest.java new file mode 100644 index 000000000..dc36b6881 --- /dev/null +++ b/lib/taskana-core-test/src/test/java/acceptance/task/requestreview/RequestReviewAccTest.java @@ -0,0 +1,197 @@ +package acceptance.task.requestreview; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static pro.taskana.testapi.DefaultTestEntities.defaultTestClassification; +import static pro.taskana.testapi.DefaultTestEntities.defaultTestWorkbasket; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.function.ThrowingConsumer; + +import pro.taskana.classification.api.ClassificationService; +import pro.taskana.classification.api.models.ClassificationSummary; +import pro.taskana.common.internal.util.EnumUtil; +import pro.taskana.task.api.TaskService; +import pro.taskana.task.api.TaskState; +import pro.taskana.task.api.exceptions.InvalidOwnerException; +import pro.taskana.task.api.exceptions.InvalidTaskStateException; +import pro.taskana.task.api.models.ObjectReference; +import pro.taskana.task.api.models.Task; +import pro.taskana.testapi.DefaultTestEntities; +import pro.taskana.testapi.TaskanaInject; +import pro.taskana.testapi.TaskanaIntegrationTest; +import pro.taskana.testapi.builder.TaskBuilder; +import pro.taskana.testapi.builder.WorkbasketAccessItemBuilder; +import pro.taskana.testapi.security.WithAccessId; +import pro.taskana.workbasket.api.WorkbasketPermission; +import pro.taskana.workbasket.api.WorkbasketService; +import pro.taskana.workbasket.api.exceptions.MismatchedWorkbasketPermissionException; +import pro.taskana.workbasket.api.models.WorkbasketSummary; + +@TaskanaIntegrationTest +class RequestReviewAccTest { + @TaskanaInject TaskService taskService; + + ClassificationSummary defaultClassificationSummary; + WorkbasketSummary defaultWorkbasketSummary; + ObjectReference defaultObjectReference; + + @WithAccessId(user = "businessadmin") + @BeforeAll + void setup(ClassificationService classificationService, WorkbasketService workbasketService) + throws Exception { + defaultClassificationSummary = + defaultTestClassification().buildAndStoreAsSummary(classificationService); + defaultWorkbasketSummary = defaultTestWorkbasket().buildAndStoreAsSummary(workbasketService); + + WorkbasketAccessItemBuilder.newWorkbasketAccessItem() + .workbasketId(defaultWorkbasketSummary.getId()) + .accessId("user-1-1") + .permission(WorkbasketPermission.READ) + .permission(WorkbasketPermission.APPEND) + .buildAndStore(workbasketService); + + defaultObjectReference = DefaultTestEntities.defaultTestObjectReference().build(); + } + + @WithAccessId(user = "user-1-1") + @Test + void should_RequestReview_WhenTaskIsClaimed() throws Exception { + Instant now = Instant.now(); + Task task = createTaskClaimedByUser("user-1-1").buildAndStore(taskService); + + Task result = taskService.requestReview(task.getId()); + + assertThat(result.getState()).isEqualTo(TaskState.READY_FOR_REVIEW); + assertThat(result.getOwner()).isNull(); + assertThat(result.getModified()).isAfterOrEqualTo(now); + } + + @WithAccessId(user = "user-1-1") + @Test + void should_ForceRequestReview_WhenTaskIsClaimedByDifferentUser() throws Exception { + Instant now = Instant.now(); + Task task = createTaskClaimedByUser("user-1-2").buildAndStore(taskService); + + Task result = taskService.forceRequestReview(task.getId()); + + assertThat(result.getState()).isEqualTo(TaskState.READY_FOR_REVIEW); + assertThat(result.getOwner()).isNull(); + assertThat(result.getModified()).isAfterOrEqualTo(now); + } + + @WithAccessId(user = "user-1-1") + @Test + void should_ForceRequestReview_WhenTaskIsInReviewByDifferentUser() throws Exception { + Instant now = Instant.now(); + Task task = + createTaskClaimedByUser("user-1-2").state(TaskState.IN_REVIEW).buildAndStore(taskService); + + Task result = taskService.forceRequestReview(task.getId()); + + assertThat(result.getState()).isEqualTo(TaskState.READY_FOR_REVIEW); + assertThat(result.getOwner()).isNull(); + assertThat(result.getModified()).isAfterOrEqualTo(now); + } + + @WithAccessId(user = "user-1-1") + @TestFactory + Stream should_ForceRequestReview_WhenTaskIsNotInEndState() { + List testCases = Arrays.asList(EnumUtil.allValuesExceptFor(TaskState.END_STATES)); + ThrowingConsumer test = + state -> { + Instant now = Instant.now(); + Task task = createDefaultTask().state(state).buildAndStore(taskService); + Task result = taskService.forceRequestReview(task.getId()); + + assertThat(result.getState()).isEqualTo(TaskState.READY_FOR_REVIEW); + assertThat(result.getOwner()).isNull(); + assertThat(result.getModified()).isAfterOrEqualTo(now); + }; + return DynamicTest.stream(testCases.iterator(), TaskState::name, test); + } + + @WithAccessId(user = "user-1-1") + @Test + void should_ThrowException_When_RequestingReviewAndTaskIsClaimedByDifferentOwner() + throws Exception { + Task task = createTaskClaimedByUser("user-1-2").buildAndStore(taskService); + + ThrowingCallable call = () -> taskService.requestReview(task.getId()); + + InvalidOwnerException e = catchThrowableOfType(call, InvalidOwnerException.class); + assertThat(e.getCurrentUserId()).isEqualTo("user-1-1"); + assertThat(e.getTaskId()).isEqualTo(task.getId()); + } + + @WithAccessId(user = "user-1-1") + @TestFactory + Stream should_ThrowException_When_RequestingReviewAndTaskIsNotClaimed() { + List invalidStates = Arrays.asList(EnumUtil.allValuesExceptFor(TaskState.CLAIMED)); + + ThrowingConsumer test = + state -> { + Task task = createDefaultTask().state(state).buildAndStore(taskService); + ThrowingCallable call = () -> taskService.requestReview(task.getId()); + + InvalidTaskStateException e = catchThrowableOfType(call, InvalidTaskStateException.class); + assertThat(e.getRequiredTaskStates()).containsExactly(TaskState.CLAIMED); + assertThat(e.getTaskState()).isEqualTo(state); + assertThat(e.getTaskId()).isEqualTo(task.getId()); + }; + return DynamicTest.stream(invalidStates.iterator(), TaskState::name, test); + } + + @WithAccessId(user = "user-1-2") + @Test + void should_ThrowException_When_UserHasNoWorkbasketPermission() throws Exception { + Task task = createTaskClaimedByUser("user-1-1").buildAndStore(taskService, "user-1-1"); + ThrowingCallable call = () -> taskService.requestReview(task.getId()); + + MismatchedWorkbasketPermissionException e = + catchThrowableOfType(call, MismatchedWorkbasketPermissionException.class); + assertThat(e.getRequiredPermissions()).containsExactly(WorkbasketPermission.READ); + assertThat(e.getCurrentUserId()).isEqualTo("user-1-2"); + assertThat(e.getWorkbasketId()).isEqualTo(defaultWorkbasketSummary.getId()); + assertThat(e.getDomain()).isNull(); + assertThat(e.getWorkbasketKey()).isNull(); + } + + @WithAccessId(user = "user-1-1") + @TestFactory + Stream should_ThrowException_When_ForceRequestReviewAndTaskIsInEndState() { + List endStates = Arrays.asList(TaskState.END_STATES); + + ThrowingConsumer test = + state -> { + Task task = createDefaultTask().state(state).buildAndStore(taskService); + ThrowingCallable call = () -> taskService.forceRequestReview(task.getId()); + + InvalidTaskStateException e = catchThrowableOfType(call, InvalidTaskStateException.class); + assertThat(e.getRequiredTaskStates()) + .containsExactlyInAnyOrder(EnumUtil.allValuesExceptFor(TaskState.END_STATES)); + assertThat(e.getTaskState()).isEqualTo(state); + assertThat(e.getTaskId()).isEqualTo(task.getId()); + }; + return DynamicTest.stream(endStates.iterator(), TaskState::name, test); + } + + private TaskBuilder createTaskClaimedByUser(String owner) { + return createDefaultTask().owner(owner).state(TaskState.CLAIMED); + } + + private TaskBuilder createDefaultTask() { + return TaskBuilder.newTask() + .classificationSummary(defaultClassificationSummary) + .workbasketSummary(defaultWorkbasketSummary) + .primaryObjRef(defaultObjectReference); + } +} diff --git a/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskHistoryEventType.java b/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskHistoryEventType.java index 4fccd9749..fdb187615 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskHistoryEventType.java +++ b/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskHistoryEventType.java @@ -5,6 +5,7 @@ public enum TaskHistoryEventType { UPDATED("UPDATED"), CLAIMED("CLAIMED"), CLAIM_CANCELLED("CLAIM_CANCELLED"), + REQUESTED_REVIEW("REQUESTED_REVIEW"), COMPLETED("COMPLETED"), CANCELLED("CANCELLED"), TERMINATED("TERMINATED"), diff --git a/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskRequestReviewEvent.java b/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskRequestReviewEvent.java new file mode 100644 index 000000000..823923c52 --- /dev/null +++ b/lib/taskana-core/src/main/java/pro/taskana/spi/history/api/events/task/TaskRequestReviewEvent.java @@ -0,0 +1,13 @@ +package pro.taskana.spi.history.api.events.task; + +import pro.taskana.task.api.models.Task; + +/** The TaskRequestReviewEvent is fired after a review on a {@linkplain Task} has been requested. */ +public class TaskRequestReviewEvent extends TaskHistoryEvent { + + public TaskRequestReviewEvent(String id, Task task, String userId, String details) { + super(id, task, userId, details); + eventType = (TaskHistoryEventType.REQUESTED_REVIEW.getName()); + created = task.getModified(); + } +} diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java b/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java index a58ce2866..6f8dd59ed 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java @@ -16,6 +16,7 @@ import pro.taskana.spi.routing.api.TaskRoutingProvider; import pro.taskana.task.api.exceptions.AttachmentPersistenceException; import pro.taskana.task.api.exceptions.InvalidOwnerException; import pro.taskana.task.api.exceptions.InvalidStateException; +import pro.taskana.task.api.exceptions.InvalidTaskStateException; import pro.taskana.task.api.exceptions.ObjectReferencePersistenceException; import pro.taskana.task.api.exceptions.TaskAlreadyExistException; import pro.taskana.task.api.exceptions.TaskCommentNotFoundException; @@ -99,6 +100,39 @@ public interface TaskService { throws TaskNotFoundException, InvalidStateException, InvalidOwnerException, NotAuthorizedException; + /** + * Request review for an existing {@linkplain Task} that is in {@linkplain TaskState#CLAIMED}. + * + * @param taskId the {@linkplain Task#getId() id} of the specified {@linkplain Task} + * @return the {@linkplain Task} after a review has been requested + * @throws InvalidTaskStateException if the {@linkplain Task#getState() state} of the {@linkplain + * Task} with taskId is not in {@linkplain TaskState#CLAIMED} + * @throws TaskNotFoundException if the {@linkplain Task} with taskId wasn't found + * @throws InvalidOwnerException if the {@linkplain Task} is claimed by another user + * @throws NotAuthorizedException if the current user has no {@linkplain + * WorkbasketPermission#READ} for the {@linkplain Workbasket} the {@linkplain Task} is in + */ + Task requestReview(String taskId) + throws InvalidTaskStateException, TaskNotFoundException, NotAuthorizedException, + InvalidOwnerException; + + /** + * Request review for an existing {@linkplain Task} even if the current user is not the + * {@linkplain Task#getOwner() owner} or the Task is not in {@linkplain TaskState#CLAIMED} yet. + * + * @param taskId the {@linkplain Task#getId() id} of the specified {@linkplain Task} + * @return the {@linkplain Task} after a review has been requested + * @throws InvalidTaskStateException if the {@linkplain Task#getState() state} of the {@linkplain + * Task} with taskId is one of the {@linkplain TaskState#END_STATES} + * @throws TaskNotFoundException if the {@linkplain Task} with taskId wasn't found + * @throws InvalidOwnerException cannot be thrown + * @throws NotAuthorizedException if the current user has no {@linkplain + * WorkbasketPermission#READ} for the {@linkplain Workbasket} the {@linkplain Task} is in + */ + Task forceRequestReview(String taskId) + throws InvalidTaskStateException, TaskNotFoundException, NotAuthorizedException, + InvalidOwnerException; + /** * Complete a claimed {@linkplain Task} as {@linkplain Task#getOwner() owner} or {@linkplain * TaskanaRole#ADMIN} and update {@linkplain Task#getState() state} and timestamps. diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java index a74abd971..0b2c71e16 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java @@ -47,6 +47,7 @@ import pro.taskana.spi.history.api.events.task.TaskClaimCancelledEvent; import pro.taskana.spi.history.api.events.task.TaskClaimedEvent; import pro.taskana.spi.history.api.events.task.TaskCompletedEvent; import pro.taskana.spi.history.api.events.task.TaskCreatedEvent; +import pro.taskana.spi.history.api.events.task.TaskRequestReviewEvent; import pro.taskana.spi.history.api.events.task.TaskTerminatedEvent; import pro.taskana.spi.history.api.events.task.TaskUpdatedEvent; import pro.taskana.spi.history.internal.HistoryEventManager; @@ -167,6 +168,20 @@ public class TaskServiceImpl implements TaskService { return this.cancelClaim(taskId, true); } + @Override + public Task requestReview(String taskId) + throws InvalidTaskStateException, TaskNotFoundException, NotAuthorizedException, + InvalidOwnerException { + return requestReview(taskId, false); + } + + @Override + public Task forceRequestReview(String taskId) + throws InvalidTaskStateException, TaskNotFoundException, NotAuthorizedException, + InvalidOwnerException { + return requestReview(taskId, true); + } + @Override public Task completeTask(String taskId) throws TaskNotFoundException, InvalidOwnerException, InvalidStateException, @@ -1163,9 +1178,7 @@ public class TaskServiceImpl implements TaskService { taskanaEngine.openConnection(); task = (TaskImpl) getTask(taskId); - TaskImpl oldTask = task.copy(); - oldTask.setId(taskId); - oldTask.setExternalId(task.getExternalId()); + TaskImpl oldTask = duplicateTaskExactly(task); Instant now = Instant.now(); checkPreconditionsForClaimTask(task, forceClaim); @@ -1191,6 +1204,53 @@ public class TaskServiceImpl implements TaskService { return task; } + private Task requestReview(String taskId, boolean force) + throws TaskNotFoundException, NotAuthorizedException, InvalidTaskStateException, + InvalidOwnerException { + String userId = taskanaEngine.getEngine().getCurrentUserContext().getUserid(); + TaskImpl task; + try { + taskanaEngine.openConnection(); + task = (TaskImpl) getTask(taskId); + + TaskImpl oldTask = duplicateTaskExactly(task); + + if (force && task.getState().isEndState()) { + throw new InvalidTaskStateException( + task.getId(), task.getState(), EnumUtil.allValuesExceptFor(TaskState.END_STATES)); + } + if (!force && task.getState() != TaskState.CLAIMED) { + throw new InvalidTaskStateException(task.getId(), task.getState(), TaskState.CLAIMED); + } + if (!force && !task.getOwner().equals(userId)) { + throw new InvalidOwnerException(userId, task.getId()); + } + + task.setState(TaskState.READY_FOR_REVIEW); + task.setOwner(null); + task.setModified(Instant.now()); + + taskMapper.update(task); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Requested review for Task '{}' by user '{}'.", taskId, userId); + } + if (historyEventManager.isEnabled()) { + String changeDetails = + ObjectAttributeChangeDetector.determineChangesInAttributes(oldTask, task); + + historyEventManager.createEvent( + new TaskRequestReviewEvent( + IdGenerator.generateWithPrefix(IdGenerator.ID_PREFIX_TASK_HISTORY_EVENT), + task, + taskanaEngine.getEngine().getCurrentUserContext().getUserid(), + changeDetails)); + } + } finally { + taskanaEngine.returnConnection(); + } + return task; + } + private static void claimActionsOnTask(TaskSummaryImpl task, String userId, Instant now) { task.setOwner(userId); task.setModified(now); @@ -1278,9 +1338,7 @@ public class TaskServiceImpl implements TaskService { taskanaEngine.openConnection(); task = (TaskImpl) getTask(taskId); - TaskImpl oldTask = task.copy(); - oldTask.setId(taskId); - oldTask.setExternalId(task.getExternalId()); + TaskImpl oldTask = duplicateTaskExactly(task); TaskState state = task.getState(); if (state.isEndState()) { @@ -1904,4 +1962,13 @@ public class TaskServiceImpl implements TaskService { task, taskanaEngine.getEngine().getCurrentUserContext().getUserid()))); } + + private TaskImpl duplicateTaskExactly(TaskImpl task) { + TaskImpl oldTask = task.copy(); + oldTask.setId(task.getId()); + oldTask.setExternalId(task.getExternalId()); + oldTask.setAttachments(task.getAttachments()); + oldTask.setSecondaryObjectReferences(task.getSecondaryObjectReferences()); + return oldTask; + } }