feat: initial approval workflow engine with in-memory and JDBC storage
Lightweight linear approval engine (v0.1): - ordo-api: domain model, OrdoEngine port, repository SPI with conditional updates (insertIfAbsent, completeIfPending) and a TransactionExecutor port for atomic multi-step writes - ordo-core: DefaultOrdoEngine running register/start/approve/reject inside a transaction boundary; in-memory engine and repositories - ordo-storage-jdbc: thread-bound JDBC transactions, normalized V1 schema migration, Jackson-based ProcessContext JSON codec - tests: unit tests plus H2 integration tests; PostgreSQL integration tests run against a local instance via ordo.test.pg.* properties and skip when unreachable Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package com.jetlumen.ordo.core;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalStep;
|
||||
import com.jetlumen.ordo.api.ApprovalTask;
|
||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||
import com.jetlumen.ordo.api.OrdoEngine;
|
||||
import com.jetlumen.ordo.api.ProcessContext;
|
||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.api.ProcessStatus;
|
||||
import com.jetlumen.ordo.api.TaskAction;
|
||||
import com.jetlumen.ordo.api.TaskStatus;
|
||||
import com.jetlumen.ordo.api.TransactionExecutor;
|
||||
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
|
||||
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
|
||||
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
|
||||
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
|
||||
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
|
||||
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
||||
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
||||
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository-backed implementation of the v0.1 linear approval runtime.
|
||||
* Multistep write operations run inside a {@link TransactionExecutor} and
|
||||
* rely on conditional repository updates, so the engine stays correct even
|
||||
* when several JVMs share the same storage.
|
||||
*/
|
||||
public final class DefaultOrdoEngine implements OrdoEngine {
|
||||
private final Clock clock;
|
||||
private final AssigneeResolver assigneeResolver;
|
||||
private final TransactionExecutor transactionExecutor;
|
||||
private final ProcessDefinitionRepository definitionRepository;
|
||||
private final ProcessInstanceRepository instanceRepository;
|
||||
private final ApprovalTaskRepository taskRepository;
|
||||
|
||||
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, TransactionExecutor transactionExecutor,
|
||||
ProcessDefinitionRepository definitionRepository,
|
||||
ProcessInstanceRepository instanceRepository,
|
||||
ApprovalTaskRepository taskRepository) {
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
this.assigneeResolver = Objects.requireNonNull(assigneeResolver, "assigneeResolver must not be null");
|
||||
this.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor must not be null");
|
||||
this.definitionRepository = Objects.requireNonNull(definitionRepository, "definitionRepository must not be null");
|
||||
this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null");
|
||||
this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void register(ProcessDefinition definition) {
|
||||
Objects.requireNonNull(definition, "definition must not be null");
|
||||
transactionExecutor.execute(() -> {
|
||||
if (!definitionRepository.insertIfAbsent(definition)) {
|
||||
throw new DefinitionAlreadyExistsException(definition.id());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
|
||||
requireText(initiator, "initiator");
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
return transactionExecutor.execute(() -> {
|
||||
ProcessDefinition definition = requireDefinition(definitionId);
|
||||
Instant now = clock.instant();
|
||||
ProcessInstance instance = new ProcessInstance(nextId(), definition.id(), initiator,
|
||||
ProcessStatus.RUNNING, now, null, context);
|
||||
instanceRepository.insert(instance);
|
||||
createTask(instance, definition.steps().getFirst(), now);
|
||||
return instance;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ApprovalTask approve(String taskId, String actor, String comment) {
|
||||
return transactionExecutor.execute(() -> {
|
||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||
Instant now = clock.instant();
|
||||
ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now));
|
||||
ProcessInstance instance = requireInstance(task.instanceId());
|
||||
ProcessDefinition definition = requireDefinition(instance.definitionId());
|
||||
int stepIndex = indexOf(definition, task.stepId());
|
||||
if (stepIndex == definition.steps().size() - 1) {
|
||||
completeInstance(instance, ProcessStatus.APPROVED, now);
|
||||
} else {
|
||||
createTask(instance, definition.steps().get(stepIndex + 1), now);
|
||||
}
|
||||
return completedTask;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ApprovalTask reject(String taskId, String actor, String comment) {
|
||||
return transactionExecutor.execute(() -> {
|
||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||
Instant now = clock.instant();
|
||||
ApprovalTask completedTask = completeTask(task, TaskStatus.REJECTED, new TaskAction(actor, comment, now));
|
||||
completeInstance(requireInstance(task.instanceId()), ProcessStatus.REJECTED, now);
|
||||
return completedTask;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<ProcessInstance> findInstance(String instanceId) {
|
||||
return instanceRepository.findById(instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<ApprovalTask> findTask(String taskId) {
|
||||
return taskRepository.findById(taskId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<ApprovalTask> findTasks(String instanceId) {
|
||||
return taskRepository.findByInstanceId(instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<ApprovalTask> findPendingTasksByAssignee(String assignee) {
|
||||
requireText(assignee, "assignee");
|
||||
return taskRepository.findPendingByAssignee(assignee);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<ApprovalTask> findPendingTasksByInstanceId(String instanceId) {
|
||||
requireText(instanceId, "instance id");
|
||||
return taskRepository.findPendingByInstanceId(instanceId);
|
||||
}
|
||||
|
||||
private void createTask(ProcessInstance instance, ApprovalStep step, Instant now) {
|
||||
String assignee = assigneeResolver.resolve(step, instance.context());
|
||||
requireText(assignee, "resolved assignee");
|
||||
taskRepository.save(new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee,
|
||||
TaskStatus.PENDING, now, null, null));
|
||||
}
|
||||
|
||||
private ApprovalTask requirePendingTaskForActor(String taskId, String actor) {
|
||||
requireText(actor, "actor");
|
||||
ApprovalTask task = taskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new TaskNotFoundException(taskId));
|
||||
if (task.status() != TaskStatus.PENDING) {
|
||||
throw new TaskAlreadyCompletedException(taskId);
|
||||
}
|
||||
if (!task.assignee().equals(actor)) {
|
||||
throw new UnauthorizedTaskOperationException(taskId, actor);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private ApprovalTask completeTask(ApprovalTask task, TaskStatus status, TaskAction action) {
|
||||
ApprovalTask completed = new ApprovalTask(task.id(), task.instanceId(), task.stepId(), task.name(),
|
||||
task.assignee(), status, task.createdAt(), action.operatedAt(), action);
|
||||
if (!taskRepository.completeIfPending(completed)) {
|
||||
throw new TaskAlreadyCompletedException(task.id());
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
private void completeInstance(ProcessInstance instance, ProcessStatus status, Instant now) {
|
||||
instanceRepository.update(new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||
status, instance.startedAt(), now, instance.context()));
|
||||
}
|
||||
|
||||
private ProcessDefinition requireDefinition(String definitionId) {
|
||||
return definitionRepository.findById(definitionId)
|
||||
.orElseThrow(() -> new DefinitionNotFoundException(definitionId));
|
||||
}
|
||||
|
||||
private ProcessInstance requireInstance(String instanceId) {
|
||||
return instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new IllegalStateException("instance not found: " + instanceId));
|
||||
}
|
||||
|
||||
private static int indexOf(ProcessDefinition definition, String stepId) {
|
||||
for (int index = 0; index < definition.steps().size(); index++) {
|
||||
if (definition.steps().get(index).id().equals(stepId)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("step not found in definition: " + stepId);
|
||||
}
|
||||
|
||||
private static String nextId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private static void requireText(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.jetlumen.ordo.core;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalTask;
|
||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||
import com.jetlumen.ordo.api.OrdoEngine;
|
||||
import com.jetlumen.ordo.api.ProcessContext;
|
||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.core.repository.InMemoryApprovalTaskRepository;
|
||||
import com.jetlumen.ordo.core.repository.InMemoryProcessDefinitionRepository;
|
||||
import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Development and test engine backed by the in-memory repository implementations. */
|
||||
public final class InMemoryOrdoEngine implements OrdoEngine {
|
||||
private final DefaultOrdoEngine delegate;
|
||||
|
||||
public InMemoryOrdoEngine() {
|
||||
this(Clock.systemUTC(), AssigneeResolver.direct());
|
||||
}
|
||||
|
||||
public InMemoryOrdoEngine(Clock clock) {
|
||||
this(clock, AssigneeResolver.direct());
|
||||
}
|
||||
|
||||
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) {
|
||||
this(Clock.systemUTC(), assigneeResolver);
|
||||
}
|
||||
|
||||
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) {
|
||||
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, new NoopTransactionExecutor(),
|
||||
new InMemoryProcessDefinitionRepository(),
|
||||
new InMemoryProcessInstanceRepository(),
|
||||
new InMemoryApprovalTaskRepository());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(ProcessDefinition definition) {
|
||||
delegate.register(definition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
|
||||
return delegate.start(definitionId, initiator, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApprovalTask approve(String taskId, String actor, String comment) {
|
||||
return delegate.approve(taskId, actor, comment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApprovalTask reject(String taskId, String actor, String comment) {
|
||||
return delegate.reject(taskId, actor, comment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProcessInstance> findInstance(String instanceId) {
|
||||
return delegate.findInstance(instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ApprovalTask> findTask(String taskId) {
|
||||
return delegate.findTask(taskId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalTask> findTasks(String instanceId) {
|
||||
return delegate.findTasks(instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalTask> findPendingTasksByAssignee(String assignee) {
|
||||
return delegate.findPendingTasksByAssignee(assignee);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalTask> findPendingTasksByInstanceId(String instanceId) {
|
||||
return delegate.findPendingTasksByInstanceId(instanceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.jetlumen.ordo.core;
|
||||
|
||||
import com.jetlumen.ordo.api.TransactionExecutor;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Transaction boundary that runs the action as-is; used by the in-memory engine. */
|
||||
public final class NoopTransactionExecutor implements TransactionExecutor {
|
||||
@Override
|
||||
public <T> T execute(Supplier<T> action) {
|
||||
Objects.requireNonNull(action, "action must not be null");
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.jetlumen.ordo.core.repository;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalTask;
|
||||
import com.jetlumen.ordo.api.TaskStatus;
|
||||
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Development-only in-memory implementation of the task storage port. */
|
||||
public final class InMemoryApprovalTaskRepository implements ApprovalTaskRepository {
|
||||
private final Map<String, ApprovalTask> tasks = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public synchronized void save(ApprovalTask task) {
|
||||
tasks.put(task.id(), task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<ApprovalTask> findById(String taskId) {
|
||||
return Optional.ofNullable(tasks.get(taskId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<ApprovalTask> findByInstanceId(String instanceId) {
|
||||
return tasks.values().stream().filter(task -> task.instanceId().equals(instanceId)).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<ApprovalTask> findPendingByAssignee(String assignee) {
|
||||
return tasks.values().stream()
|
||||
.filter(task -> task.status() == TaskStatus.PENDING)
|
||||
.filter(task -> task.assignee().equals(assignee))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<ApprovalTask> findPendingByInstanceId(String instanceId) {
|
||||
return tasks.values().stream()
|
||||
.filter(task -> task.status() == TaskStatus.PENDING)
|
||||
.filter(task -> task.instanceId().equals(instanceId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean completeIfPending(ApprovalTask completedTask) {
|
||||
ApprovalTask current = tasks.get(completedTask.id());
|
||||
if (current == null || current.status() != TaskStatus.PENDING) {
|
||||
return false;
|
||||
}
|
||||
tasks.put(completedTask.id(), completedTask);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.jetlumen.ordo.core.repository;
|
||||
|
||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Development-only in-memory implementation of the definition storage port. */
|
||||
public final class InMemoryProcessDefinitionRepository implements ProcessDefinitionRepository {
|
||||
private final Map<String, ProcessDefinition> definitions = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public synchronized boolean insertIfAbsent(ProcessDefinition definition) {
|
||||
return definitions.putIfAbsent(definition.id(), definition) == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<ProcessDefinition> findById(String definitionId) {
|
||||
return Optional.ofNullable(definitions.get(definitionId));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.jetlumen.ordo.core.repository;
|
||||
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Development-only in-memory implementation of the instance storage port. */
|
||||
public final class InMemoryProcessInstanceRepository implements ProcessInstanceRepository {
|
||||
private final Map<String, ProcessInstance> instances = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public synchronized void insert(ProcessInstance instance) {
|
||||
instances.put(instance.id(), instance);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void update(ProcessInstance instance) {
|
||||
instances.put(instance.id(), instance);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<ProcessInstance> findById(String instanceId) {
|
||||
return Optional.ofNullable(instances.get(instanceId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.jetlumen.ordo.core;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalStep;
|
||||
import com.jetlumen.ordo.api.ApprovalTask;
|
||||
import com.jetlumen.ordo.api.ProcessContext;
|
||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||
import com.jetlumen.ordo.api.ProcessStatus;
|
||||
import com.jetlumen.ordo.api.TaskStatus;
|
||||
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
|
||||
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
|
||||
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
|
||||
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
|
||||
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class InMemoryOrdoEngineTest {
|
||||
private InMemoryOrdoEngine engine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
engine = new InMemoryOrdoEngine();
|
||||
engine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
||||
new ApprovalStep("hr", "HR approval", "henry")
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesASequentialApprovalProcess() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask managerTask = engine.findTasks(instance.id()).getFirst();
|
||||
|
||||
assertEquals(TaskStatus.APPROVED, engine.approve(managerTask.id(), "maria").status());
|
||||
ApprovalTask hrTask = engine.findTasks(instance.id()).get(1);
|
||||
assertEquals("henry", hrTask.assignee());
|
||||
engine.approve(hrTask.id(), "henry");
|
||||
|
||||
assertEquals(ProcessStatus.APPROVED, engine.findInstance(instance.id()).orElseThrow().status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionTerminatesTheProcess() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask task = engine.findTasks(instance.id()).getFirst();
|
||||
|
||||
ApprovalTask rejectedTask = engine.reject(task.id(), "maria", "Insufficient leave balance");
|
||||
|
||||
assertEquals(TaskStatus.REJECTED, rejectedTask.status());
|
||||
assertEquals("maria", rejectedTask.action().actor());
|
||||
assertEquals("Insufficient leave balance", rejectedTask.action().comment());
|
||||
assertEquals(rejectedTask.completedAt(), rejectedTask.action().operatedAt());
|
||||
assertEquals(ProcessStatus.REJECTED, engine.findInstance(instance.id()).orElseThrow().status());
|
||||
assertEquals(1, engine.findTasks(instance.id()).size());
|
||||
assertThrows(TaskAlreadyCompletedException.class, () -> engine.approve(task.id(), "maria"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateAndUnauthorizedOperations() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask task = engine.findTasks(instance.id()).getFirst();
|
||||
|
||||
assertThrows(UnauthorizedTaskOperationException.class, () -> engine.approve(task.id(), "mallory"));
|
||||
engine.approve(task.id(), "maria");
|
||||
assertThrows(TaskAlreadyCompletedException.class, () -> engine.approve(task.id(), "maria"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesSpecificExceptionsForMissingAndDuplicateResources() {
|
||||
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
|
||||
assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria"));
|
||||
assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(new ProcessDefinition(
|
||||
"leave", "Another leave request", List.of(new ApprovalStep("lead", "Lead approval", "lee")))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankRuntimeArguments() {
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " "));
|
||||
assertThrows(DefinitionNotFoundException.class, () -> engine.start(" ", "alice"));
|
||||
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask task = engine.findTasks(instance.id()).getFirst();
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSeparateInstancesIndependent() {
|
||||
var aliceInstance = engine.start("leave", "alice");
|
||||
var bobInstance = engine.start("leave", "bob");
|
||||
ApprovalTask aliceTask = engine.findTasks(aliceInstance.id()).getFirst();
|
||||
|
||||
engine.approve(aliceTask.id(), "maria");
|
||||
|
||||
assertEquals(2, engine.findTasks(aliceInstance.id()).size());
|
||||
assertEquals(1, engine.findTasks(bobInstance.id()).size());
|
||||
assertEquals(ProcessStatus.RUNNING, engine.findInstance(aliceInstance.id()).orElseThrow().status());
|
||||
assertEquals(ProcessStatus.RUNNING, engine.findInstance(bobInstance.id()).orElseThrow().status());
|
||||
assertEquals("maria", engine.findTasks(bobInstance.id()).getFirst().assignee());
|
||||
assertTrue(engine.findTasks("missing-instance").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findsOnlyCurrentPendingTasksByAssigneeAndInstance() {
|
||||
var aliceInstance = engine.start("leave", "alice");
|
||||
var bobInstance = engine.start("leave", "bob");
|
||||
ApprovalTask aliceManagerTask = engine.findPendingTasksByInstanceId(aliceInstance.id()).getFirst();
|
||||
|
||||
assertEquals(2, engine.findPendingTasksByAssignee("maria").size());
|
||||
assertEquals(List.of(aliceManagerTask), engine.findPendingTasksByInstanceId(aliceInstance.id()));
|
||||
assertTrue(engine.findPendingTasksByAssignee("henry").isEmpty());
|
||||
|
||||
engine.approve(aliceManagerTask.id(), "maria");
|
||||
ApprovalTask hrTask = engine.findPendingTasksByInstanceId(aliceInstance.id()).getFirst();
|
||||
|
||||
assertEquals("hr", hrTask.stepId());
|
||||
assertEquals("henry", hrTask.assignee());
|
||||
assertEquals(1, engine.findPendingTasksByAssignee("maria").size());
|
||||
assertEquals(List.of(hrTask), engine.findPendingTasksByAssignee("henry"));
|
||||
|
||||
engine.reject(hrTask.id(), "henry");
|
||||
assertTrue(engine.findPendingTasksByInstanceId(aliceInstance.id()).isEmpty());
|
||||
assertTrue(engine.findPendingTasksByAssignee("henry").isEmpty());
|
||||
assertEquals(1, engine.findPendingTasksByInstanceId(bobInstance.id()).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsApprovalAuditDataAndLeavesPendingTasksWithoutAnAction() {
|
||||
var instance = engine.start("leave", "alice");
|
||||
ApprovalTask managerTask = engine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
||||
|
||||
assertNull(managerTask.action());
|
||||
ApprovalTask approvedTask = engine.approve(managerTask.id(), "maria", " Approved for the requested dates. ");
|
||||
|
||||
assertEquals("maria", approvedTask.action().actor());
|
||||
assertEquals("Approved for the requested dates.", approvedTask.action().comment());
|
||||
assertEquals(approvedTask.completedAt(), approvedTask.action().operatedAt());
|
||||
assertNotNull(approvedTask.action().operatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankPendingTaskQueryArguments() {
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.findPendingTasksByAssignee(" "));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.findPendingTasksByInstanceId(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesAssigneesFromTheProcessContext() {
|
||||
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((step, context) -> context.value(step.id())
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.orElse(step.assignee()));
|
||||
contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
||||
new ApprovalStep("hr", "HR approval", "henry")
|
||||
)));
|
||||
|
||||
var instance = contextAwareEngine.start("leave", "alice", new ProcessContext(Map.of(
|
||||
"manager", "david",
|
||||
"hr", "helena"
|
||||
)));
|
||||
ApprovalTask managerTask = contextAwareEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
||||
|
||||
assertEquals("david", managerTask.assignee());
|
||||
contextAwareEngine.approve(managerTask.id(), "david");
|
||||
assertEquals("helena", contextAwareEngine.findPendingTasksByInstanceId(instance.id()).getFirst().assignee());
|
||||
assertEquals("david", instance.context().value("manager").orElseThrow());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user