From 87b449e45cfece0b21ac0be0ad4e5b1ee8d7b99c Mon Sep 17 00:00:00 2001 From: 0264408 Date: Fri, 11 Sep 2026 16:39:17 +0800 Subject: [PATCH] feat: let initiators withdraw running process instances Co-authored-by: Cursor --- .../com/jetlumen/ordo/api/OrdoEngine.java | 4 ++ .../com/jetlumen/ordo/api/ProcessStatus.java | 2 +- .../InstanceAlreadyCompletedException.java | 7 ++++ .../exception/InstanceNotFoundException.java | 7 ++++ ...nauthorizedInstanceOperationException.java | 7 ++++ .../repository/ProcessInstanceRepository.java | 7 ++++ .../jetlumen/ordo/core/DefaultOrdoEngine.java | 39 ++++++++++++++++++- .../ordo/core/InMemoryOrdoEngine.java | 5 +++ .../InMemoryProcessInstanceRepository.java | 10 +++++ .../ordo/core/InMemoryOrdoEngineTest.java | 39 +++++++++++++++++++ .../ordo/spring/OrdoDefinitionLoader.java | 7 +++- .../spring/OrdoJdbcAutoConfigurationTest.java | 18 +++++++++ .../jdbc/JdbcProcessInstanceRepository.java | 16 ++++++++ .../jdbc/JdbcOrdoEngineIntegrationTest.java | 30 ++++++++++++++ .../JdbcProcessInstanceRepositoryTest.java | 19 +++++++++ 15 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceAlreadyCompletedException.java create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceNotFoundException.java create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/exception/UnauthorizedInstanceOperationException.java diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java index 041d68f..5810b5d 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java @@ -24,6 +24,10 @@ public interface OrdoEngine { return reject(taskId, actor, null); } ApprovalTask reject(String taskId, String actor, String comment); + default ProcessInstance withdraw(String instanceId, String actor) { + return withdraw(instanceId, actor, null); + } + ProcessInstance withdraw(String instanceId, String actor, String comment); Optional findInstance(String instanceId); Optional findTask(String taskId); List findTasks(String instanceId); diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java index db61a15..30c39cb 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java @@ -1,5 +1,5 @@ package com.jetlumen.ordo.api; public enum ProcessStatus { - RUNNING, APPROVED, REJECTED + RUNNING, APPROVED, REJECTED, WITHDRAWN } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceAlreadyCompletedException.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceAlreadyCompletedException.java new file mode 100644 index 0000000..34e80e7 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceAlreadyCompletedException.java @@ -0,0 +1,7 @@ +package com.jetlumen.ordo.api.exception; + +public final class InstanceAlreadyCompletedException extends OrdoException { + public InstanceAlreadyCompletedException(String instanceId) { + super("instance is already completed: " + instanceId); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceNotFoundException.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceNotFoundException.java new file mode 100644 index 0000000..86d8de8 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/InstanceNotFoundException.java @@ -0,0 +1,7 @@ +package com.jetlumen.ordo.api.exception; + +public final class InstanceNotFoundException extends OrdoException { + public InstanceNotFoundException(String instanceId) { + super("instance not found: " + instanceId); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/UnauthorizedInstanceOperationException.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/UnauthorizedInstanceOperationException.java new file mode 100644 index 0000000..463ef39 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/UnauthorizedInstanceOperationException.java @@ -0,0 +1,7 @@ +package com.jetlumen.ordo.api.exception; + +public final class UnauthorizedInstanceOperationException extends OrdoException { + public UnauthorizedInstanceOperationException(String instanceId, String actor) { + super("actor '" + actor + "' is not the initiator for instance: " + instanceId); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java index 094e956..f15a7ec 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java @@ -15,4 +15,11 @@ public interface ProcessInstanceRepository { Optional findById(String instanceId); boolean existsRunning(String definitionId); + + /** + * Completes the instance only if it is still running. + * + * @return true if the update was applied, false if the instance was missing or already terminal + */ + boolean completeIfRunning(ProcessInstance completed); } diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java index f079dc3..f36ee28 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java @@ -17,9 +17,12 @@ import com.jetlumen.ordo.api.TransactionExecutor; import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import com.jetlumen.ordo.api.exception.DefinitionInUseException; import com.jetlumen.ordo.api.exception.DefinitionNotFoundException; +import com.jetlumen.ordo.api.exception.InstanceAlreadyCompletedException; +import com.jetlumen.ordo.api.exception.InstanceNotFoundException; import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskNotFoundException; +import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException; import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException; import com.jetlumen.ordo.api.repository.ApprovalTaskRepository; import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository; @@ -123,6 +126,35 @@ public final class DefaultOrdoEngine implements OrdoEngine { }); } + @Override + public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) { + requireText(instanceId, "instance id"); + requireText(actor, "actor"); + return transactionExecutor.execute(() -> { + ProcessInstance instance = instanceRepository.findById(instanceId) + .orElseThrow(() -> new InstanceNotFoundException(instanceId)); + if (!instance.initiator().equals(actor)) { + throw new UnauthorizedInstanceOperationException(instanceId, actor); + } + if (instance.status() != ProcessStatus.RUNNING) { + throw new InstanceAlreadyCompletedException(instanceId); + } + Instant now = clock.instant(); + ProcessInstance withdrawn = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(), + ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context()); + if (!instanceRepository.completeIfRunning(withdrawn)) { + throw new InstanceAlreadyCompletedException(instanceId); + } + TaskAction action = new TaskAction(actor, comment, now); + for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) { + ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), + pending.name(), pending.assignee(), TaskStatus.SKIPPED, pending.createdAt(), now, action); + taskRepository.completeIfPending(skipped); + } + return withdrawn; + }); + } + @Override public synchronized Optional findInstance(String instanceId) { return instanceRepository.findById(instanceId); @@ -259,8 +291,11 @@ public final class DefaultOrdoEngine implements OrdoEngine { } 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())); + ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(), + status, instance.startedAt(), now, instance.context()); + if (!instanceRepository.completeIfRunning(completed)) { + throw new InstanceAlreadyCompletedException(instance.id()); + } } private ProcessDefinition requireDefinition(String definitionId) { diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java index 08a76a6..14e925b 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java @@ -75,6 +75,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine { return delegate.reject(taskId, actor, comment); } + @Override + public ProcessInstance withdraw(String instanceId, String actor, String comment) { + return delegate.withdraw(instanceId, actor, comment); + } + @Override public Optional findInstance(String instanceId) { return delegate.findInstance(instanceId); diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java index 30ade3e..9f7c506 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java @@ -33,4 +33,14 @@ public final class InMemoryProcessInstanceRepository implements ProcessInstanceR .anyMatch(instance -> instance.definitionId().equals(definitionId) && instance.status() == ProcessStatus.RUNNING); } + + @Override + public synchronized boolean completeIfRunning(ProcessInstance completed) { + ProcessInstance current = instances.get(completed.id()); + if (current == null || current.status() != ProcessStatus.RUNNING) { + return false; + } + instances.put(completed.id(), completed); + return true; + } } diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java index 58b187c..cd2cf3e 100644 --- a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java @@ -5,6 +5,7 @@ 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.ProcessInstance; import com.jetlumen.ordo.api.ProcessStatus; import com.jetlumen.ordo.api.RoutingCondition; import com.jetlumen.ordo.api.StepTransition; @@ -12,9 +13,12 @@ import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import com.jetlumen.ordo.api.exception.DefinitionInUseException; import com.jetlumen.ordo.api.exception.DefinitionNotFoundException; +import com.jetlumen.ordo.api.exception.InstanceAlreadyCompletedException; +import com.jetlumen.ordo.api.exception.InstanceNotFoundException; import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskNotFoundException; +import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException; import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -127,6 +131,41 @@ class InMemoryOrdoEngineTest { assertEquals("diana", engine.findTasks(next.id()).getFirst().assignee()); } + @Test + void initiatorCanWithdrawWhileALaterStepIsPending() { + var instance = engine.start("leave", "alice"); + engine.approve(engine.findTasks(instance.id()).getFirst().id(), "maria"); + + ProcessInstance withdrawn = engine.withdraw(instance.id(), "alice", "changed plans"); + + assertEquals(ProcessStatus.WITHDRAWN, withdrawn.status()); + assertEquals(ProcessStatus.WITHDRAWN, engine.findInstance(instance.id()).orElseThrow().status()); + assertTrue(engine.findPendingTasksByInstanceId(instance.id()).isEmpty()); + ApprovalTask hrTask = engine.findTasks(instance.id()).stream() + .filter(task -> task.stepId().equals("hr")) + .findFirst() + .orElseThrow(); + assertEquals(TaskStatus.SKIPPED, hrTask.status()); + assertEquals("alice", hrTask.action().actor()); + assertEquals("changed plans", hrTask.action().comment()); + assertEquals(TaskStatus.APPROVED, engine.findTasks(instance.id()).stream() + .filter(task -> task.stepId().equals("manager")) + .findFirst() + .orElseThrow() + .status()); + } + + @Test + void withdrawIsRejectedForNonInitiatorMissingAndCompletedInstances() { + var instance = engine.start("leave", "alice"); + assertThrows(UnauthorizedInstanceOperationException.class, + () -> engine.withdraw(instance.id(), "mallory")); + assertThrows(InstanceNotFoundException.class, () -> engine.withdraw("missing", "alice")); + + engine.reject(engine.findTasks(instance.id()).getFirst().id(), "maria"); + assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice")); + } + @Test void rejectsBlankRuntimeArguments() { assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " ")); diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java index fb3673f..8a14f03 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java @@ -3,6 +3,7 @@ package com.jetlumen.ordo.spring; import com.jetlumen.ordo.api.OrdoEngine; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinitionParser; +import com.jetlumen.ordo.api.exception.DefinitionInUseException; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.io.Resource; @@ -48,7 +49,11 @@ public final class OrdoDefinitionLoader implements ApplicationRunner { } catch (RuntimeException e) { throw new IllegalStateException("failed to parse ordo definition from " + describe(resource), e); } - engine.replace(definition); + try { + engine.replace(definition); + } catch (DefinitionInUseException ignored) { + // keep the stored graph while RUNNING instances still reference it + } } } diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java index 05099f9..b047d26 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java @@ -111,6 +111,24 @@ class OrdoJdbcAutoConfigurationTest { }); } + @Test + void keepsStoredDefinitionWhenReloadFindsRunningInstances() { + withDataSourceRunner.run(context -> { + OrdoEngine engine = context.getBean(OrdoEngine.class); + engine.replace(ProcessDefinition.linear("leave-request-routed", "stale", List.of( + ApprovalStep.single("lead", "Lead approval", "lee")))); + engine.start("leave-request-routed", "alice"); + + context.getBean(OrdoDefinitionLoader.class).load(); + + ProcessDefinition definition = context.getBean(ProcessDefinitionRepository.class) + .findById("leave-request-routed") + .orElseThrow(); + assertThat(definition.name()).isEqualTo("stale"); + assertThat(definition.steps()).hasSize(1); + }); + } + @Test void honoursUserDefinedRoutingCondition() { withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class) diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java index c285c03..b6c56b7 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java @@ -19,6 +19,8 @@ public final class JdbcProcessInstanceRepository implements ProcessInstanceRepos + " VALUES (?, ?, ?, ?, ?, ?, ?)"; private static final String UPDATE_INSTANCE = "UPDATE ordo_process_instance SET status = ?, finished_at = ? WHERE id = ?"; + private static final String COMPLETE_IF_RUNNING = + "UPDATE ordo_process_instance SET status = ?, finished_at = ? WHERE id = ? AND status = 'RUNNING'"; private static final String SELECT_INSTANCE = "SELECT id, definition_id, initiator, status, context_json, started_at, finished_at" + " FROM ordo_process_instance WHERE id = ?"; @@ -91,4 +93,18 @@ public final class JdbcProcessInstanceRepository implements ProcessInstanceRepos connectionProvider.close(connection); } } + + @Override + public boolean completeIfRunning(ProcessInstance completed) { + Objects.requireNonNull(completed, "completed must not be null"); + Connection connection = connectionProvider.getConnection(); + try (PreparedStatement update = connection.prepareStatement(COMPLETE_IF_RUNNING)) { + ProcessInstanceMapper.bindUpdate(update, completed); + return update.executeUpdate() == 1; + } catch (SQLException e) { + throw new JdbcStorageException("failed to complete instance: " + completed.id(), e); + } finally { + connectionProvider.close(connection); + } + } } diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java index 7fb964f..d7eb340 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java @@ -12,6 +12,8 @@ import com.jetlumen.ordo.api.RoutingCondition; import com.jetlumen.ordo.api.StepTransition; import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.exception.DefinitionInUseException; +import com.jetlumen.ordo.api.exception.InstanceAlreadyCompletedException; +import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException; import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.core.DefaultOrdoEngine; @@ -165,6 +167,34 @@ class JdbcOrdoEngineIntegrationTest { "leave", "Leave request v2", List.of(ApprovalStep.single("director", "Director approval", "diana"))))); } + @Test + void initiatorCanWithdrawWhileALaterStepIsPending() { + ProcessInstance instance = engine.start("leave", "alice"); + engine.approve(engine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria"); + + ProcessInstance withdrawn = engine.withdraw(instance.id(), "alice", "changed plans"); + + assertEquals(ProcessStatus.WITHDRAWN, withdrawn.status()); + assertEquals(NOW, withdrawn.finishedAt()); + assertTrue(engine.findPendingTasksByInstanceId(instance.id()).isEmpty()); + ApprovalTask hrTask = engine.findTasks(instance.id()).stream() + .filter(task -> task.stepId().equals("hr")) + .findFirst() + .orElseThrow(); + assertEquals(TaskStatus.SKIPPED, hrTask.status()); + assertEquals("alice", hrTask.action().actor()); + assertEquals("changed plans", hrTask.action().comment()); + } + + @Test + void withdrawIsRejectedForNonInitiatorAndCompletedInstances() { + ProcessInstance instance = engine.start("leave", "alice"); + assertThrows(UnauthorizedInstanceOperationException.class, + () -> engine.withdraw(instance.id(), "mallory")); + engine.reject(engine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria"); + assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice")); + } + private OrdoEngine newEngine(AssigneeResolver assigneeResolver) { return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(), new JdbcTransactionExecutor(connectionProvider), diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java index 82f80ee..80cd099 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java @@ -81,6 +81,25 @@ class JdbcProcessInstanceRepositoryTest { assertFalse(repository.existsRunning("leave")); } + @Test + void completeIfRunningOnlyUpdatesARunningInstance() { + ProcessInstance running = new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING, + STARTED_AT, null, ProcessContext.empty()); + repository.insert(running); + + Instant finishedAt = STARTED_AT.plusSeconds(30); + ProcessInstance withdrawn = new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.WITHDRAWN, + STARTED_AT, finishedAt, ProcessContext.empty()); + assertTrue(repository.completeIfRunning(withdrawn)); + assertEquals(ProcessStatus.WITHDRAWN, repository.findById("inst-1").orElseThrow().status()); + assertEquals(finishedAt, repository.findById("inst-1").orElseThrow().finishedAt()); + + assertFalse(repository.completeIfRunning(new ProcessInstance("inst-1", "leave", "alice", + ProcessStatus.APPROVED, STARTED_AT, finishedAt, ProcessContext.empty()))); + assertFalse(repository.completeIfRunning(new ProcessInstance("missing", "leave", "alice", + ProcessStatus.WITHDRAWN, STARTED_AT, finishedAt, ProcessContext.empty()))); + } + @Test void updateOfAnUnknownInstanceFails() { assertThrows(IllegalStateException.class, () -> repository.update(new ProcessInstance("missing", "leave",