Implementierung für M2 vorläufig abgeschlossen
This commit is contained in:
@@ -2,7 +2,11 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(xargs grep:*)",
|
"Bash(xargs grep:*)",
|
||||||
"Bash(xargs wc:*)"
|
"Bash(xargs wc:*)",
|
||||||
|
"Bash(mvn clean:*)",
|
||||||
|
"Bash(mvn verify:*)",
|
||||||
|
"Bash(mvn test:*)",
|
||||||
|
"Bash(find D:/Dev/Projects/pdf-umbenenner-parent -not -path */target/* -type d)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;
|
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI command adapter for batch processing scheduling.
|
* CLI command adapter for batch processing scheduling.
|
||||||
* <p>
|
* <p>
|
||||||
* This class acts as the technical entry point that delegates to the application layer.
|
* This class acts as the inbound technical entry point that exclusively delegates
|
||||||
|
* to the application layer's batch processing use case via the {@link RunBatchProcessingUseCase}
|
||||||
|
* interface. It receives the batch run outcome and makes it available to the Bootstrap layer
|
||||||
|
* for exit code determination and logging.
|
||||||
|
* <p>
|
||||||
* AP-003 Implementation: Minimal no-op command to validate the call chain from CLI to Application.
|
* AP-003 Implementation: Minimal no-op command to validate the call chain from CLI to Application.
|
||||||
|
* <p>
|
||||||
|
* M2-AP-002 Update: Returns {@link BatchRunOutcome} instead of boolean,
|
||||||
|
* allowing Bootstrap to systematically derive exit codes (AP-007).
|
||||||
|
* <p>
|
||||||
|
* M2-AP-003 Update: Accepts {@link BatchRunContext} and passes it to the use case,
|
||||||
|
* enabling run ID and timing tracking throughout the batch cycle.
|
||||||
|
* <p>
|
||||||
|
* M2-AP-005 Update: Dependency inversion achieved - this adapter depends only on the
|
||||||
|
* RunBatchProcessingUseCase interface, not on any concrete implementation. Bootstrap
|
||||||
|
* is responsible for injecting the appropriate use case implementation.
|
||||||
*/
|
*/
|
||||||
public class SchedulerBatchCommand {
|
public class SchedulerBatchCommand {
|
||||||
|
|
||||||
@@ -22,13 +38,14 @@ public class SchedulerBatchCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the batch processing command.
|
* Executes the batch processing command with the given run context.
|
||||||
* <p>
|
* <p>
|
||||||
* AP-003: Delegates to the use case without any additional logic.
|
* Delegates to the use case with the batch run context and returns its structured outcome.
|
||||||
*
|
*
|
||||||
* @return true if execution succeeded, false otherwise
|
* @param context the technical context for this batch run
|
||||||
|
* @return {@link BatchRunOutcome} describing the result of batch processing
|
||||||
*/
|
*/
|
||||||
public boolean run() {
|
public BatchRunOutcome run(BatchRunContext context) {
|
||||||
return useCase.execute();
|
return useCase.execute(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* CLI adapter for inbound commands.
|
* Inbound adapter for CLI/batch command processing.
|
||||||
* This package contains the technical entry points that delegate to application use cases.
|
|
||||||
* <p>
|
* <p>
|
||||||
* AP-003: Contains minimal command classes for validating the startup path.
|
* This package contains the technical entry points that delegate exclusively to application
|
||||||
|
* use cases via their inbound port interfaces. The adapter enforces strict dependency inversion:
|
||||||
|
* it depends on the application's inbound port contracts, not on concrete implementations.
|
||||||
|
* <p>
|
||||||
|
* Components:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.inbound.cli.SchedulerBatchCommand}
|
||||||
|
* — CLI entry point that delegates to RunBatchProcessingUseCase interface (AP-005)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* M2-AP-005 Architecture:
|
||||||
|
* <ul>
|
||||||
|
* <li>Adapter depends on: {@link de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase} (interface)</li>
|
||||||
|
* <li>Adapter does not depend on: any concrete use case implementation</li>
|
||||||
|
* <li>Bootstrap wires concrete use case implementation via constructor injection</li>
|
||||||
|
* <li>Dependency direction: Adapter IN → (through port) → Application</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;
|
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link SchedulerBatchCommand}.
|
||||||
|
* <p>
|
||||||
|
* Verifies that the CLI adapter correctly delegates to the batch processing use case
|
||||||
|
* and returns the outcome without transformation.
|
||||||
|
*/
|
||||||
|
class SchedulerBatchCommandTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_acceptsRunBatchProcessingUseCase() {
|
||||||
|
RunBatchProcessingUseCase mockUseCase = (context) -> BatchRunOutcome.SUCCESS;
|
||||||
|
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(mockUseCase);
|
||||||
|
|
||||||
|
assertNotNull(command, "Constructor should create a valid SchedulerBatchCommand");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_delegatesToUseCaseAndReturnsOutcome() {
|
||||||
|
// Setup: mock use case that returns SUCCESS
|
||||||
|
RunBatchProcessingUseCase mockUseCase = (context) -> BatchRunOutcome.SUCCESS;
|
||||||
|
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(mockUseCase);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test-run"), Instant.now());
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
BatchRunOutcome outcome = command.run(context);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assertEquals(BatchRunOutcome.SUCCESS, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_passesContextToUseCase() {
|
||||||
|
RunId expectedRunId = new RunId("test-run-123");
|
||||||
|
Instant expectedStartTime = Instant.now();
|
||||||
|
|
||||||
|
// Setup: mock use case that captures the context
|
||||||
|
MockCapturingUseCase mockUseCase = new MockCapturingUseCase();
|
||||||
|
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(mockUseCase);
|
||||||
|
BatchRunContext context = new BatchRunContext(expectedRunId, expectedStartTime);
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
command.run(context);
|
||||||
|
|
||||||
|
// Verify context was passed correctly
|
||||||
|
assertNotNull(mockUseCase.capturedContext);
|
||||||
|
assertEquals(expectedRunId, mockUseCase.capturedContext.runId());
|
||||||
|
assertEquals(expectedStartTime, mockUseCase.capturedContext.startInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_returnsSuccessOutcome() {
|
||||||
|
RunBatchProcessingUseCase successUseCase = (context) -> BatchRunOutcome.SUCCESS;
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(successUseCase);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test"), Instant.now());
|
||||||
|
|
||||||
|
BatchRunOutcome outcome = command.run(context);
|
||||||
|
|
||||||
|
assertEquals(BatchRunOutcome.SUCCESS, outcome);
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
assertFalse(outcome.isFailure());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_returnsFailureOutcome() {
|
||||||
|
RunBatchProcessingUseCase failureUseCase = (context) -> BatchRunOutcome.FAILURE;
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(failureUseCase);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test"), Instant.now());
|
||||||
|
|
||||||
|
BatchRunOutcome outcome = command.run(context);
|
||||||
|
|
||||||
|
assertEquals(BatchRunOutcome.FAILURE, outcome);
|
||||||
|
assertTrue(outcome.isFailure());
|
||||||
|
assertFalse(outcome.isSuccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_returnsLockUnavailableOutcome() {
|
||||||
|
RunBatchProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE;
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(lockUnavailableUseCase);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test"), Instant.now());
|
||||||
|
|
||||||
|
BatchRunOutcome outcome = command.run(context);
|
||||||
|
|
||||||
|
assertEquals(BatchRunOutcome.LOCK_UNAVAILABLE, outcome);
|
||||||
|
assertTrue(outcome.isLockUnavailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_multipleCallsWithDifferentContexts() {
|
||||||
|
RunBatchProcessingUseCase mockUseCase = (context) -> BatchRunOutcome.SUCCESS;
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(mockUseCase);
|
||||||
|
|
||||||
|
BatchRunContext context1 = new BatchRunContext(new RunId("run-1"), Instant.now());
|
||||||
|
BatchRunContext context2 = new BatchRunContext(new RunId("run-2"), Instant.now());
|
||||||
|
|
||||||
|
// Execute multiple times
|
||||||
|
BatchRunOutcome outcome1 = command.run(context1);
|
||||||
|
BatchRunOutcome outcome2 = command.run(context2);
|
||||||
|
|
||||||
|
// Verify both succeed independently
|
||||||
|
assertEquals(BatchRunOutcome.SUCCESS, outcome1);
|
||||||
|
assertEquals(BatchRunOutcome.SUCCESS, outcome2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_delegatesWithoutModifyingContext() {
|
||||||
|
RunId runId = new RunId("immutable-test");
|
||||||
|
Instant startTime = Instant.parse("2026-03-31T20:00:00Z");
|
||||||
|
|
||||||
|
RunBatchProcessingUseCase mockUseCase = (context) -> {
|
||||||
|
// Verify context state is not modified
|
||||||
|
assertEquals(runId, context.runId());
|
||||||
|
assertEquals(startTime, context.startInstant());
|
||||||
|
assertFalse(context.isCompleted());
|
||||||
|
return BatchRunOutcome.SUCCESS;
|
||||||
|
};
|
||||||
|
|
||||||
|
SchedulerBatchCommand command = new SchedulerBatchCommand(mockUseCase);
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
|
||||||
|
command.run(context);
|
||||||
|
// After run, context should still be incomplete (not modified by adapter)
|
||||||
|
assertFalse(context.isCompleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock use case that captures the passed context for verification.
|
||||||
|
*/
|
||||||
|
private static class MockCapturingUseCase implements RunBatchProcessingUseCase {
|
||||||
|
BatchRunContext capturedContext;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BatchRunOutcome execute(BatchRunContext context) {
|
||||||
|
this.capturedContext = context;
|
||||||
|
return BatchRunOutcome.SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.outbound.lock;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs.
|
||||||
|
* <p>
|
||||||
|
* AP-006 Implementation: Creates an exclusive lock file on acquire and deletes it on release.
|
||||||
|
* If the lock file already exists, {@link #acquire()} throws {@link RunLockUnavailableException}
|
||||||
|
* to signal that another instance is already running.
|
||||||
|
* <p>
|
||||||
|
* The lock file contains the PID of the acquiring process. Release is best-effort: a failure
|
||||||
|
* to delete the lock file is logged as a warning but does not throw.
|
||||||
|
*/
|
||||||
|
public class FilesystemRunLockPortAdapter implements RunLockPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(FilesystemRunLockPortAdapter.class);
|
||||||
|
|
||||||
|
private final Path lockFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new FilesystemRunLockPortAdapter for the given lock file path.
|
||||||
|
*
|
||||||
|
* @param lockFile path of the lock file to create on acquire and delete on release
|
||||||
|
*/
|
||||||
|
public FilesystemRunLockPortAdapter(Path lockFile) {
|
||||||
|
this.lockFile = lockFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires the run lock by creating the lock file.
|
||||||
|
* <p>
|
||||||
|
* If the lock file already exists, throws {@link RunLockUnavailableException}.
|
||||||
|
* If the parent directory does not exist, it is created before attempting file creation.
|
||||||
|
*
|
||||||
|
* @throws RunLockUnavailableException if the lock file already exists or cannot be created
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void acquire() {
|
||||||
|
if (Files.exists(lockFile)) {
|
||||||
|
throw new RunLockUnavailableException(
|
||||||
|
"Run lock file already exists - another instance may be running: " + lockFile);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path parent = lockFile.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
long pid = ProcessHandle.current().pid();
|
||||||
|
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
|
||||||
|
LOG.debug("Run lock acquired: {} (PID {})", lockFile, pid);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RunLockUnavailableException("Failed to acquire run lock file: " + lockFile, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the run lock by deleting the lock file.
|
||||||
|
* <p>
|
||||||
|
* If deletion fails, a warning is logged but no exception is thrown.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
try {
|
||||||
|
boolean deleted = Files.deleteIfExists(lockFile);
|
||||||
|
if (deleted) {
|
||||||
|
LOG.debug("Run lock released: {}", lockFile);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("Failed to release run lock file: {} — manual cleanup may be required", lockFile, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Outbound adapter for run lock management.
|
||||||
|
* <p>
|
||||||
|
* Components:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.outbound.lock.FilesystemRunLockPortAdapter}
|
||||||
|
* — File-based run lock that prevents concurrent instances (AP-006)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* AP-006: Uses atomic file creation ({@code CREATE_NEW}) to establish an exclusive lock.
|
||||||
|
* Stores the acquiring process PID in the lock file for diagnostics.
|
||||||
|
* Release is best-effort and logs a warning on failure without throwing.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.outbound.lock;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.outbound.lock;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link FilesystemRunLockPortAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Tests cover acquire/release success path, lock contention behavior,
|
||||||
|
* and robust release behavior.
|
||||||
|
*/
|
||||||
|
class FilesystemRunLockPortAdapterTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquire_createsLockFile() {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
adapter.acquire();
|
||||||
|
|
||||||
|
assertTrue(Files.exists(lockFile), "Lock file should exist after acquire");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquire_writesProcessPidToLockFile() throws Exception {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
adapter.acquire();
|
||||||
|
|
||||||
|
String content = Files.readString(lockFile);
|
||||||
|
long pid = ProcessHandle.current().pid();
|
||||||
|
assertEquals(String.valueOf(pid), content, "Lock file should contain current PID");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void release_deletesLockFile() throws Exception {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
adapter.acquire();
|
||||||
|
assertTrue(Files.exists(lockFile));
|
||||||
|
|
||||||
|
adapter.release();
|
||||||
|
|
||||||
|
assertFalse(Files.exists(lockFile), "Lock file should be deleted after release");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void release_doesNotThrowIfLockFileAbsent() {
|
||||||
|
Path lockFile = tempDir.resolve("nonexistent.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
assertDoesNotThrow(adapter::release, "Release without prior acquire should not throw");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquire_throwsRunLockUnavailableExceptionIfLockFileExists() throws Exception {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
Files.writeString(lockFile, "12345");
|
||||||
|
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
assertThrows(RunLockUnavailableException.class, adapter::acquire,
|
||||||
|
"Acquire should throw RunLockUnavailableException when lock file already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquire_exceptionMessageContainsLockFilePath() throws Exception {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
Files.writeString(lockFile, "12345");
|
||||||
|
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
RunLockUnavailableException ex = assertThrows(RunLockUnavailableException.class, adapter::acquire);
|
||||||
|
assertTrue(ex.getMessage().contains(lockFile.toString()),
|
||||||
|
"Exception message should contain lock file path");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquire_createsParentDirectoriesIfAbsent() {
|
||||||
|
Path lockFile = tempDir.resolve("nested").resolve("deep").resolve("test.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
adapter.acquire();
|
||||||
|
|
||||||
|
assertTrue(Files.exists(lockFile), "Lock file should be created including parent dirs");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquireAndRelease_canBeRepeated() {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
adapter.acquire();
|
||||||
|
adapter.release();
|
||||||
|
adapter.acquire();
|
||||||
|
adapter.release();
|
||||||
|
|
||||||
|
assertFalse(Files.exists(lockFile), "Lock file should not exist after second release");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquire_secondCallThrowsRunLockUnavailableException() {
|
||||||
|
Path lockFile = tempDir.resolve("test.lock");
|
||||||
|
FilesystemRunLockPortAdapter adapter = new FilesystemRunLockPortAdapter(lockFile);
|
||||||
|
|
||||||
|
adapter.acquire();
|
||||||
|
|
||||||
|
assertThrows(RunLockUnavailableException.class, adapter::acquire,
|
||||||
|
"Second acquire without release should throw RunLockUnavailableException");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object representing the outcome of a batch processing run.
|
||||||
|
* <p>
|
||||||
|
* This enum encodes the high-level result of executing a complete batch cycle,
|
||||||
|
* allowing Bootstrap and CLI layers to map outcomes to exit codes and log messages
|
||||||
|
* in a controlled and consistent manner.
|
||||||
|
* <p>
|
||||||
|
* The outcome is independent of individual document processing results;
|
||||||
|
* it represents the batch operation itself (lock acquired, no critical startup failure, etc.).
|
||||||
|
* <p>
|
||||||
|
* Design Note: This contract is defined in AP-002 to enable AP-007 (exit code handling)
|
||||||
|
* to derive exit codes systematically without requiring additional knowledge about
|
||||||
|
* the batch run. Each outcome maps cleanly to an exit code semantic.
|
||||||
|
* <p>
|
||||||
|
* AP-007: Three distinct outcomes are now defined to make the difference between a
|
||||||
|
* technically successful run, a controlled early termination due to start protection,
|
||||||
|
* and a hard bootstrap failure explicit in both code and logs.
|
||||||
|
*
|
||||||
|
* @since M2-AP-002
|
||||||
|
*/
|
||||||
|
public enum BatchRunOutcome {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch processing completed successfully.
|
||||||
|
* <p>
|
||||||
|
* The run acquired the lock, started cleanly, and completed the full processing cycle.
|
||||||
|
* Individual documents within the run may have succeeded or failed, but that is
|
||||||
|
* transparent to this outcome. The run itself executed as intended.
|
||||||
|
* <p>
|
||||||
|
* Maps to exit code 0.
|
||||||
|
*/
|
||||||
|
SUCCESS("Batch processing completed successfully"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch run aborted because another instance is already running.
|
||||||
|
* <p>
|
||||||
|
* The run lock could not be acquired because a concurrent instance holds it.
|
||||||
|
* This is a controlled, expected termination (start protection), not a hard failure.
|
||||||
|
* The new instance terminates immediately without performing any processing.
|
||||||
|
* <p>
|
||||||
|
* Maps to exit code 1.
|
||||||
|
*
|
||||||
|
* @since M2-AP-007
|
||||||
|
*/
|
||||||
|
LOCK_UNAVAILABLE("Another instance is already running; this run terminates immediately"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch processing failed due to an unexpected error.
|
||||||
|
* <p>
|
||||||
|
* The run encountered a critical error that prevented normal batch completion,
|
||||||
|
* such as an unrecoverable I/O error or infrastructure failure.
|
||||||
|
* <p>
|
||||||
|
* Maps to exit code 1.
|
||||||
|
*/
|
||||||
|
FAILURE("Batch processing failed");
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
BatchRunOutcome(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable message for this outcome.
|
||||||
|
*
|
||||||
|
* @return descriptive message suitable for logging
|
||||||
|
*/
|
||||||
|
public String message() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this outcome represents successful completion.
|
||||||
|
*
|
||||||
|
* @return true if outcome is {@link #SUCCESS}, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return this == SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this outcome represents any non-successful result.
|
||||||
|
* <p>
|
||||||
|
* Both {@link #FAILURE} and {@link #LOCK_UNAVAILABLE} are considered failures
|
||||||
|
* for the purpose of exit code derivation (both map to exit code 1).
|
||||||
|
* Use {@link #isLockUnavailable()} to distinguish the start-protection case.
|
||||||
|
*
|
||||||
|
* @return true if outcome is {@link #FAILURE} or {@link #LOCK_UNAVAILABLE}, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isFailure() {
|
||||||
|
return this == FAILURE || this == LOCK_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this outcome represents a controlled early termination due to
|
||||||
|
* the run lock being held by another instance.
|
||||||
|
*
|
||||||
|
* @return true if outcome is {@link #LOCK_UNAVAILABLE}, false otherwise
|
||||||
|
* @since M2-AP-007
|
||||||
|
*/
|
||||||
|
public boolean isLockUnavailable() {
|
||||||
|
return this == LOCK_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,51 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inbound port for batch processing execution.
|
* Inbound port for batch processing execution.
|
||||||
* This interface defines the contract for triggering batch operations.
|
|
||||||
* <p>
|
* <p>
|
||||||
* AP-003 Implementation: Currently a no-op placeholder to establish the technical startup path.
|
* This interface defines the contract for triggering batch operations.
|
||||||
|
* It is the central use case entry point for the entire application.
|
||||||
|
* <p>
|
||||||
|
* Responsibilities:
|
||||||
|
* <ul>
|
||||||
|
* <li>Orchestrate the batch run lifecycle (start, process, end)</li>
|
||||||
|
* <li>Interact with all outbound ports (lock, clock, configuration, persistence, etc.)</li>
|
||||||
|
* <li>Return a structured outcome ({@link BatchRunOutcome}) that describes the run result</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* The returned outcome is designed to be independent of individual document results,
|
||||||
|
* representing only the batch operation itself. Individual document successes/failures
|
||||||
|
* are tracked separately in persistence (future milestones).
|
||||||
|
* <p>
|
||||||
|
* M2-AP-002 Implementation:
|
||||||
|
* <ul>
|
||||||
|
* <li>Port is defined with a structured return contract ({@link BatchRunOutcome})</li>
|
||||||
|
* <li>Return model allows Bootstrap/CLI to systematically derive exit codes (AP-007)</li>
|
||||||
|
* <li>No implementation of the use case itself yet (that is AP-004)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* M2-AP-003 Update:
|
||||||
|
* <ul>
|
||||||
|
* <li>execute() now accepts a {@link BatchRunContext} containing the run ID and timing</li>
|
||||||
|
* <li>The context flows through the entire batch cycle for correlation and logging</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public interface RunBatchProcessingUseCase {
|
public interface RunBatchProcessingUseCase {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the batch processing workflow.
|
* Executes the batch processing workflow.
|
||||||
* <p>
|
* <p>
|
||||||
* AP-003: This method performs no actual work, only validates the call chain.
|
* The method orchestrates the entire batch cycle, from startup through completion.
|
||||||
|
* It interacts with all necessary outbound ports and returns a structured outcome.
|
||||||
|
* <p>
|
||||||
|
* The outcome is not dependent on how many documents succeeded or failed within the run;
|
||||||
|
* rather, it reflects the batch operation's overall health. Individual document results
|
||||||
|
* are recorded separately in persistence.
|
||||||
*
|
*
|
||||||
* @return true if the workflow completed successfully, false otherwise
|
* @param context the technical context for this batch run, containing run ID and timing
|
||||||
|
* @return {@link BatchRunOutcome} indicating success or failure of the batch operation
|
||||||
*/
|
*/
|
||||||
boolean execute();
|
BatchRunOutcome execute(BatchRunContext context);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Inbound ports (application service interfaces) for the hexagonal architecture.
|
* Inbound ports (use cases) for the application.
|
||||||
* Use cases in this package are invoked by adapters from the outside world.
|
* <p>
|
||||||
|
* Inbound ports define the contracts for interacting with the application from external adapters.
|
||||||
|
* All calls flow FROM adapters INTO the application through these ports.
|
||||||
|
* <p>
|
||||||
|
* Central inbound port:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase}
|
||||||
|
* — The primary use case for executing a complete batch run</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Return models:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome}
|
||||||
|
* — Structured result of a batch run, designed for exit code mapping (AP-007)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
|
||||||
|
* They define "what can be done to the application". All dependencies point inward;
|
||||||
|
* adapters depend on ports, not vice versa.
|
||||||
|
*
|
||||||
|
* @since M2-AP-002
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound port for system time access.
|
||||||
|
* <p>
|
||||||
|
* This port abstracts access to the system clock, enabling the batch run to:
|
||||||
|
* <ul>
|
||||||
|
* <li>Record timestamps for batch run start and completion</li>
|
||||||
|
* <li>Generate or verify document timestamps (later in M5+)</li>
|
||||||
|
* <li>Support testing with controlled time values</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* By isolating time access behind a port, the application can be tested with
|
||||||
|
* deterministic time values without requiring system clock manipulation.
|
||||||
|
* <p>
|
||||||
|
* This port is defined in M2 for use in later milestones where timestamps
|
||||||
|
* become relevant (e.g., run history, document date fallback).
|
||||||
|
*
|
||||||
|
* @since M2-AP-002
|
||||||
|
*/
|
||||||
|
public interface ClockPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current moment in time.
|
||||||
|
* <p>
|
||||||
|
* Implementations should return the current system time (or a controlled test value).
|
||||||
|
* The returned instant is UTC-based and suitable for persistent storage and logging.
|
||||||
|
*
|
||||||
|
* @return the current instant
|
||||||
|
*/
|
||||||
|
Instant now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound port for exclusive run locking.
|
||||||
|
* <p>
|
||||||
|
* This port abstracts the mechanism for ensuring that only one instance of the PDF Umbenenner
|
||||||
|
* is executing at any given time. The port defines the contract without prescribing the
|
||||||
|
* implementation (e.g., file-based locks, OS-level locks, distributed locks).
|
||||||
|
* <p>
|
||||||
|
* Responsibilities:
|
||||||
|
* <ul>
|
||||||
|
* <li>Guarantee exclusive access to shared resources (SQLite database, target directory)</li>
|
||||||
|
* <li>Prevent concurrent batch runs from overwriting each other's work or causing inconsistencies</li>
|
||||||
|
* <li>Allow controlled startup failure if another instance is already running</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Lock Lifecycle:
|
||||||
|
* <ul>
|
||||||
|
* <li>Acquire the lock at batch startup (before any processing)</li>
|
||||||
|
* <li>Hold the lock for the entire batch run</li>
|
||||||
|
* <li>Release the lock cleanly at batch end (even on failure, if possible)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* This port is used by the batch use case (M2-AP-004) but not implemented in M2;
|
||||||
|
* implementation follows in M2-AP-006.
|
||||||
|
*
|
||||||
|
* @since M2-AP-002
|
||||||
|
*/
|
||||||
|
public interface RunLockPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires an exclusive lock for the batch run.
|
||||||
|
* <p>
|
||||||
|
* This method blocks or throws an exception if the lock cannot be acquired
|
||||||
|
* (e.g., another instance already holds it). The behavior depends on the implementation.
|
||||||
|
* <p>
|
||||||
|
* If this method returns normally, the caller holds the lock and must ensure
|
||||||
|
* {@link #release()} is called to free it, typically in a finally block.
|
||||||
|
*
|
||||||
|
* @throws RunLockUnavailableException if the lock cannot be acquired
|
||||||
|
* (e.g., another instance already holds it or system error prevents acquiring)
|
||||||
|
* @throws RuntimeException for other critical lock-related failures
|
||||||
|
*/
|
||||||
|
void acquire();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the exclusive lock held by this batch run.
|
||||||
|
* <p>
|
||||||
|
* This method is called after batch processing completes (successfully or not)
|
||||||
|
* to allow other instances to run.
|
||||||
|
* <p>
|
||||||
|
* Implementations should handle the case where release is called multiple times
|
||||||
|
* or when no lock is currently held, avoiding exceptions if possible.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException if lock release fails critically
|
||||||
|
*/
|
||||||
|
void release();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when the run lock cannot be acquired.
|
||||||
|
* <p>
|
||||||
|
* This indicates that another instance of the PDF Umbenenner is already running
|
||||||
|
* and holding the exclusive lock. The current instance should terminate immediately
|
||||||
|
* and allow the running instance to complete.
|
||||||
|
* <p>
|
||||||
|
* This is a controlled failure mode, not a programming error. Applications should
|
||||||
|
* catch this exception and exit gracefully with the appropriate exit code.
|
||||||
|
*
|
||||||
|
* @since M2-AP-002
|
||||||
|
*/
|
||||||
|
public class RunLockUnavailableException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new RunLockUnavailableException with the given message.
|
||||||
|
*
|
||||||
|
* @param message description of why the lock could not be acquired
|
||||||
|
*/
|
||||||
|
public RunLockUnavailableException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new RunLockUnavailableException with the given message and cause.
|
||||||
|
*
|
||||||
|
* @param message description of why the lock could not be acquired
|
||||||
|
* @param cause the underlying exception that prevented lock acquisition
|
||||||
|
*/
|
||||||
|
public RunLockUnavailableException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* Outbound ports for the application layer.
|
* Outbound ports (interfaces) for the application.
|
||||||
* Defines interfaces for infrastructure access from the application layer.
|
* <p>
|
||||||
|
* Outbound ports define the contracts for interacting with external systems and infrastructure.
|
||||||
|
* All calls flow FROM the application OUT TO infrastructure adapters through these ports.
|
||||||
|
* <p>
|
||||||
|
* M2-AP-002 ports:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort}
|
||||||
|
* — Loading application startup configuration (already M1)</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort}
|
||||||
|
* — Exclusive run lock for preventing concurrent batch instances</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.ClockPort}
|
||||||
|
* — System time access for timestamps and run context</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Exception types:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException}
|
||||||
|
* — Thrown when run lock cannot be acquired (another instance running)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Architecture Rule: Outbound ports are implementation-agnostic and contain no business logic.
|
||||||
|
* They define "what the application depends on externally". All dependencies point inward;
|
||||||
|
* the application depends on ports, adapters implement ports.
|
||||||
|
*
|
||||||
|
* @since M2-AP-002
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M2 implementation of {@link RunBatchProcessingUseCase}.
|
||||||
|
* <p>
|
||||||
|
* This use case orchestrates the batch processing workflow with start protection
|
||||||
|
* and controlled execution lifecycle, but without actual document processing.
|
||||||
|
* <p>
|
||||||
|
* Responsibilities:
|
||||||
|
* <ul>
|
||||||
|
* <li>Acquire exclusive run lock to prevent concurrent instances</li>
|
||||||
|
* <li>Initialize batch execution with the provided run context</li>
|
||||||
|
* <li>Coordinate outbound port interactions (configuration, lock management)</li>
|
||||||
|
* <li>Release lock and finish cleanly regardless of execution path</li>
|
||||||
|
* <li>Return structured outcome for Bootstrap exit code mapping</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* M2 Non-Goals (not implemented):
|
||||||
|
* <ul>
|
||||||
|
* <li>No source folder scanning</li>
|
||||||
|
* <li>No PDF filtering or text extraction</li>
|
||||||
|
* <li>No fingerprinting</li>
|
||||||
|
* <li>No SQLite persistence</li>
|
||||||
|
* <li>No AI integration</li>
|
||||||
|
* <li>No filename generation</li>
|
||||||
|
* <li>No target file copying</li>
|
||||||
|
* <li>No business-level retry logic</li>
|
||||||
|
* <li>No single-document processing</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since M2-AP-004
|
||||||
|
*/
|
||||||
|
public class M2BatchRunProcessingUseCase implements RunBatchProcessingUseCase {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(M2BatchRunProcessingUseCase.class);
|
||||||
|
|
||||||
|
private final ConfigurationPort configurationPort;
|
||||||
|
private final RunLockPort runLockPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the M2 batch use case with required outbound ports.
|
||||||
|
*
|
||||||
|
* @param configurationPort for loading startup configuration
|
||||||
|
* @param runLockPort for exclusive run locking
|
||||||
|
* @throws NullPointerException if any port is null
|
||||||
|
*/
|
||||||
|
public M2BatchRunProcessingUseCase(ConfigurationPort configurationPort, RunLockPort runLockPort) {
|
||||||
|
this.configurationPort = configurationPort;
|
||||||
|
this.runLockPort = runLockPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BatchRunOutcome execute(BatchRunContext context) {
|
||||||
|
LOG.info("M2 batch processing initiated with RunId: {}", context.runId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Acquire exclusive run lock (prevents concurrent instances)
|
||||||
|
try {
|
||||||
|
runLockPort.acquire();
|
||||||
|
LOG.debug("Run lock acquired successfully.");
|
||||||
|
} catch (RunLockUnavailableException e) {
|
||||||
|
LOG.warn("Run lock not available – another instance is already running. This instance terminates immediately.");
|
||||||
|
return BatchRunOutcome.LOCK_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Load configuration (already validated in Bootstrap, but accessible to use case)
|
||||||
|
StartConfiguration config = configurationPort.loadConfiguration();
|
||||||
|
LOG.debug("Configuration available: source={}, target={}", config.sourceFolder(), config.targetFolder());
|
||||||
|
|
||||||
|
// Step 3: M2 Batch execution frame (no document processing)
|
||||||
|
LOG.info("Batch execution frame initialized - RunId: {}, Start: {}", context.runId(), context.startInstant());
|
||||||
|
|
||||||
|
// M2 Non-goal: No source folder scanning, PDF processing, persistence, or filename generation
|
||||||
|
// This is a controlled no-op batch cycle that validates the entire orchestration path.
|
||||||
|
|
||||||
|
LOG.info("Batch execution frame completed successfully");
|
||||||
|
return BatchRunOutcome.SUCCESS;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Unexpected error during batch orchestration
|
||||||
|
LOG.error("Unexpected error during batch processing", e);
|
||||||
|
return BatchRunOutcome.FAILURE;
|
||||||
|
} finally {
|
||||||
|
// Step 4: Always release the run lock (critical for M2 start protection)
|
||||||
|
try {
|
||||||
|
runLockPort.release();
|
||||||
|
LOG.debug("Run lock released");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("Warning: Failed to release run lock", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
|
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
@@ -14,6 +16,11 @@ import org.apache.logging.log4j.Logger;
|
|||||||
* without any business logic, PDF processing, or infrastructure access.
|
* without any business logic, PDF processing, or infrastructure access.
|
||||||
* <p>
|
* <p>
|
||||||
* AP-005: Accepts {@link ConfigurationPort} to load typed startup configuration.
|
* AP-005: Accepts {@link ConfigurationPort} to load typed startup configuration.
|
||||||
|
* <p>
|
||||||
|
* M2-AP-002 Update: Returns {@link BatchRunOutcome} instead of boolean,
|
||||||
|
* enabling structured result handling by Bootstrap and CLI layers.
|
||||||
|
* <p>
|
||||||
|
* M2-AP-003 Update: Accepts {@link BatchRunContext} to enable run ID and timing tracking.
|
||||||
*/
|
*/
|
||||||
public class NoOpRunBatchProcessingUseCase implements RunBatchProcessingUseCase {
|
public class NoOpRunBatchProcessingUseCase implements RunBatchProcessingUseCase {
|
||||||
|
|
||||||
@@ -30,12 +37,16 @@ public class NoOpRunBatchProcessingUseCase implements RunBatchProcessingUseCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean execute() {
|
public BatchRunOutcome execute(BatchRunContext context) {
|
||||||
// AP-005: Load configuration through the port (technical loading only)
|
// AP-005: Load configuration through the port (technical loading only)
|
||||||
StartConfiguration config = configurationPort.loadConfiguration();
|
StartConfiguration config = configurationPort.loadConfiguration();
|
||||||
LOG.info("Configuration loaded successfully. Source: {}, Target: {}", config.sourceFolder(), config.targetFolder());
|
LOG.info("Configuration loaded successfully. Source: {}, Target: {}", config.sourceFolder(), config.targetFolder());
|
||||||
|
|
||||||
|
// M2-AP-003: Log run context information for traceability
|
||||||
|
LOG.info("Batch run started with RunId: {}, started at: {}", context.runId(), context.startInstant());
|
||||||
|
|
||||||
// AP-003: Intentional no-op - validates the technical call chain only
|
// AP-003: Intentional no-op - validates the technical call chain only
|
||||||
return true;
|
// M2-AP-002: Return structured outcome instead of boolean
|
||||||
|
return BatchRunOutcome.SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Use case implementations that realize the inbound port contracts.
|
* Use case implementations that realize the inbound port contracts.
|
||||||
* Currently contains minimal no-op implementations for AP-003 technical validation.
|
* <p>
|
||||||
|
* Implementations:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.usecase.NoOpRunBatchProcessingUseCase}
|
||||||
|
* — Minimal no-op for technical validation without start protection</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.usecase.M2BatchRunProcessingUseCase}
|
||||||
|
* — M2 production implementation with run lock and controlled batch cycle (AP-004)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* All implementations are infrastructure-agnostic and interact only through ports.
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link M2BatchRunProcessingUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Verifies correct orchestration of the M2 batch cycle including lock management,
|
||||||
|
* configuration loading, and controlled execution flow.
|
||||||
|
*/
|
||||||
|
class M2BatchRunProcessingUseCaseTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_successfullyAcquiresAndReleasesLock() {
|
||||||
|
// Setup mock ports that track invocations
|
||||||
|
MockRunLockPort lockPort = new MockRunLockPort();
|
||||||
|
MockConfigurationPort configPort = new MockConfigurationPort(tempDir);
|
||||||
|
|
||||||
|
M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(configPort, lockPort);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test-run-1"), Instant.now());
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
BatchRunOutcome outcome = useCase.execute(context);
|
||||||
|
|
||||||
|
// Verify lock lifecycle
|
||||||
|
assertTrue(lockPort.wasAcquireCalled(), "Lock acquire should be called");
|
||||||
|
assertTrue(lockPort.wasReleaseCalled(), "Lock release should be called");
|
||||||
|
assertTrue(outcome.isSuccess(), "Batch should complete successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_returnsLockUnavailableWhenLockCannotBeAcquired() {
|
||||||
|
// Setup: lock port that fails to acquire (another instance is running)
|
||||||
|
RunLockPort lockPort = new RunLockPort() {
|
||||||
|
@Override
|
||||||
|
public void acquire() {
|
||||||
|
throw new RunLockUnavailableException("Another instance already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
// Nothing to release
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurationPort configPort = new MockConfigurationPort(tempDir);
|
||||||
|
M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(configPort, lockPort);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test-run-2"), Instant.now());
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
BatchRunOutcome outcome = useCase.execute(context);
|
||||||
|
|
||||||
|
// AP-007: lock unavailable is a distinct, controlled early-termination outcome
|
||||||
|
assertTrue(outcome.isLockUnavailable(), "Outcome should be LOCK_UNAVAILABLE when lock cannot be acquired");
|
||||||
|
assertTrue(outcome.isFailure(), "LOCK_UNAVAILABLE also reports as failure for exit code derivation");
|
||||||
|
assertFalse(outcome.isSuccess(), "Batch should not succeed when lock unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_releasesLockEvenOnError() {
|
||||||
|
// Setup: mock lock port that tracks release calls
|
||||||
|
MockRunLockPort lockPort = new MockRunLockPort();
|
||||||
|
|
||||||
|
// Config port that throws exception
|
||||||
|
ConfigurationPort configPort = () -> {
|
||||||
|
throw new RuntimeException("Configuration loading error");
|
||||||
|
};
|
||||||
|
|
||||||
|
M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(configPort, lockPort);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test-run-3"), Instant.now());
|
||||||
|
|
||||||
|
// Execute (expect failure due to config error)
|
||||||
|
BatchRunOutcome outcome = useCase.execute(context);
|
||||||
|
|
||||||
|
// Verify lock is still released despite error
|
||||||
|
assertTrue(lockPort.wasReleaseCalled(), "Lock should be released even on configuration error");
|
||||||
|
assertTrue(outcome.isFailure(), "Batch should fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_loadsConfigurationDuringExecution() {
|
||||||
|
// Setup
|
||||||
|
MockRunLockPort lockPort = new MockRunLockPort();
|
||||||
|
MockConfigurationPort configPort = new MockConfigurationPort(tempDir);
|
||||||
|
|
||||||
|
M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(configPort, lockPort);
|
||||||
|
BatchRunContext context = new BatchRunContext(new RunId("test-run-4"), Instant.now());
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
BatchRunOutcome outcome = useCase.execute(context);
|
||||||
|
|
||||||
|
// Verify configuration was loaded
|
||||||
|
assertTrue(configPort.wasLoadConfigurationCalled(), "Configuration should be loaded");
|
||||||
|
assertTrue(outcome.isSuccess(), "Batch should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock ConfigurationPort for testing.
|
||||||
|
*/
|
||||||
|
private static class MockConfigurationPort implements ConfigurationPort {
|
||||||
|
private final Path tempDir;
|
||||||
|
private boolean loadConfigurationCalled = false;
|
||||||
|
|
||||||
|
MockConfigurationPort(Path tempDir) {
|
||||||
|
this.tempDir = tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StartConfiguration loadConfiguration() {
|
||||||
|
loadConfigurationCalled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
|
||||||
|
Path targetDir = Files.createDirectories(tempDir.resolve("target"));
|
||||||
|
Path dbFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||||
|
Path promptFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||||
|
|
||||||
|
return new StartConfiguration(
|
||||||
|
sourceDir,
|
||||||
|
targetDir,
|
||||||
|
dbFile,
|
||||||
|
URI.create("https://api.example.com"),
|
||||||
|
"gpt-4",
|
||||||
|
30,
|
||||||
|
3,
|
||||||
|
100,
|
||||||
|
50000,
|
||||||
|
promptFile,
|
||||||
|
tempDir.resolve("lock.lock"),
|
||||||
|
tempDir.resolve("logs"),
|
||||||
|
"INFO",
|
||||||
|
"test-key"
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to create mock configuration", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean wasLoadConfigurationCalled() {
|
||||||
|
return loadConfigurationCalled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock RunLockPort for testing.
|
||||||
|
*/
|
||||||
|
private static class MockRunLockPort implements RunLockPort {
|
||||||
|
private boolean acquireCalled = false;
|
||||||
|
private boolean releaseCalled = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void acquire() {
|
||||||
|
acquireCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
releaseCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean wasAcquireCalled() {
|
||||||
|
return acquireCalled;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean wasReleaseCalled() {
|
||||||
|
return releaseCalled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,20 @@ import org.apache.logging.log4j.Logger;
|
|||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.inbound.cli.SchedulerBatchCommand;
|
import de.gecheckt.pdf.umbenenner.adapter.inbound.cli.SchedulerBatchCommand;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.outbound.configuration.PropertiesConfigurationPortAdapter;
|
import de.gecheckt.pdf.umbenenner.adapter.outbound.configuration.PropertiesConfigurationPortAdapter;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.outbound.lock.FilesystemRunLockPortAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.InvalidStartConfigurationException;
|
import de.gecheckt.pdf.umbenenner.application.config.InvalidStartConfigurationException;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator;
|
import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.NoOpRunBatchProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.M2BatchRunProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual bootstrap runner that constructs the object graph and drives the startup flow.
|
* Manual bootstrap runner that constructs the object graph and drives the startup flow.
|
||||||
@@ -17,7 +26,7 @@ import de.gecheckt.pdf.umbenenner.application.usecase.NoOpRunBatchProcessingUseC
|
|||||||
* AP-003 Implementation: Creates all required components using plain Java constructor injection
|
* AP-003 Implementation: Creates all required components using plain Java constructor injection
|
||||||
* and executes the minimal no-op batch processing path.
|
* and executes the minimal no-op batch processing path.
|
||||||
* <p>
|
* <p>
|
||||||
* AP-005: Integrates configuration loading via PropertiesConfigurationPortAdapter.
|
* AP-005: CLI adapter and bootstrap wiring for M2 batch orchestration with run lock integration.
|
||||||
* <p>
|
* <p>
|
||||||
* AP-006: Validates configuration before processing begins, returns exit code 1 on invalid config.
|
* AP-006: Validates configuration before processing begins, returns exit code 1 on invalid config.
|
||||||
*/
|
*/
|
||||||
@@ -26,6 +35,7 @@ public class BootstrapRunner {
|
|||||||
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
|
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
|
||||||
|
|
||||||
private final ConfigurationPortFactory configPortFactory;
|
private final ConfigurationPortFactory configPortFactory;
|
||||||
|
private final RunLockPortFactory runLockPortFactory;
|
||||||
private final ValidatorFactory validatorFactory;
|
private final ValidatorFactory validatorFactory;
|
||||||
private final UseCaseFactory useCaseFactory;
|
private final UseCaseFactory useCaseFactory;
|
||||||
private final CommandFactory commandFactory;
|
private final CommandFactory commandFactory;
|
||||||
@@ -38,6 +48,14 @@ public class BootstrapRunner {
|
|||||||
ConfigurationPort create();
|
ConfigurationPort create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functional interface for creating a RunLockPort from the configured lock file path.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RunLockPortFactory {
|
||||||
|
RunLockPort create(Path lockFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Functional interface for creating a StartConfigurationValidator.
|
* Functional interface for creating a StartConfigurationValidator.
|
||||||
*/
|
*/
|
||||||
@@ -51,7 +69,7 @@ public class BootstrapRunner {
|
|||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface UseCaseFactory {
|
public interface UseCaseFactory {
|
||||||
RunBatchProcessingUseCase create(ConfigurationPort configPort);
|
RunBatchProcessingUseCase create(ConfigurationPort configPort, RunLockPort runLockPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,11 +82,14 @@ public class BootstrapRunner {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the BootstrapRunner with default factories for production use.
|
* Creates the BootstrapRunner with default factories for production use.
|
||||||
|
* <p>
|
||||||
|
* AP-006: Uses FilesystemRunLockPortAdapter for file-based exclusive run locking.
|
||||||
*/
|
*/
|
||||||
public BootstrapRunner() {
|
public BootstrapRunner() {
|
||||||
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
|
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
|
||||||
|
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
|
||||||
this.validatorFactory = StartConfigurationValidator::new;
|
this.validatorFactory = StartConfigurationValidator::new;
|
||||||
this.useCaseFactory = NoOpRunBatchProcessingUseCase::new;
|
this.useCaseFactory = M2BatchRunProcessingUseCase::new;
|
||||||
this.commandFactory = SchedulerBatchCommand::new;
|
this.commandFactory = SchedulerBatchCommand::new;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +97,18 @@ public class BootstrapRunner {
|
|||||||
* Creates the BootstrapRunner with custom factories for testing.
|
* Creates the BootstrapRunner with custom factories for testing.
|
||||||
*
|
*
|
||||||
* @param configPortFactory factory for creating ConfigurationPort instances
|
* @param configPortFactory factory for creating ConfigurationPort instances
|
||||||
|
* @param runLockPortFactory factory for creating RunLockPort instances
|
||||||
* @param validatorFactory factory for creating StartConfigurationValidator instances
|
* @param validatorFactory factory for creating StartConfigurationValidator instances
|
||||||
* @param useCaseFactory factory for creating RunBatchProcessingUseCase instances
|
* @param useCaseFactory factory for creating RunBatchProcessingUseCase instances
|
||||||
* @param commandFactory factory for creating SchedulerBatchCommand instances
|
* @param commandFactory factory for creating SchedulerBatchCommand instances
|
||||||
*/
|
*/
|
||||||
public BootstrapRunner(ConfigurationPortFactory configPortFactory,
|
public BootstrapRunner(ConfigurationPortFactory configPortFactory,
|
||||||
|
RunLockPortFactory runLockPortFactory,
|
||||||
ValidatorFactory validatorFactory,
|
ValidatorFactory validatorFactory,
|
||||||
UseCaseFactory useCaseFactory,
|
UseCaseFactory useCaseFactory,
|
||||||
CommandFactory commandFactory) {
|
CommandFactory commandFactory) {
|
||||||
this.configPortFactory = configPortFactory;
|
this.configPortFactory = configPortFactory;
|
||||||
|
this.runLockPortFactory = runLockPortFactory;
|
||||||
this.validatorFactory = validatorFactory;
|
this.validatorFactory = validatorFactory;
|
||||||
this.useCaseFactory = useCaseFactory;
|
this.useCaseFactory = useCaseFactory;
|
||||||
this.commandFactory = commandFactory;
|
this.commandFactory = commandFactory;
|
||||||
@@ -112,20 +136,37 @@ public class BootstrapRunner {
|
|||||||
StartConfigurationValidator validator = validatorFactory.create();
|
StartConfigurationValidator validator = validatorFactory.create();
|
||||||
validator.validate(config);
|
validator.validate(config);
|
||||||
|
|
||||||
// Step 4: Create the use case with the configuration port (application layer)
|
// Step 4: Create the run lock port from the validated config (AP-006)
|
||||||
RunBatchProcessingUseCase useCase = useCaseFactory.create(configPort);
|
RunLockPort runLockPort = runLockPortFactory.create(config.runtimeLockFile());
|
||||||
|
|
||||||
// Step 5: Create the CLI command adapter with the use case
|
// Step 5: Create the batch run context (M2-AP-003)
|
||||||
|
// Generate a unique run ID and initialize the run context
|
||||||
|
RunId runId = new RunId(UUID.randomUUID().toString());
|
||||||
|
BatchRunContext runContext = new BatchRunContext(runId, Instant.now());
|
||||||
|
LOG.info("Batch run started. RunId: {}", runId);
|
||||||
|
|
||||||
|
// Step 6: Create the use case with the configuration port and run lock (application layer)
|
||||||
|
RunBatchProcessingUseCase useCase = useCaseFactory.create(configPort, runLockPort);
|
||||||
|
|
||||||
|
// Step 7: Create the CLI command adapter with the use case
|
||||||
SchedulerBatchCommand command = commandFactory.create(useCase);
|
SchedulerBatchCommand command = commandFactory.create(useCase);
|
||||||
|
|
||||||
// Step 6: Execute the command
|
// Step 8: Execute the command with the run context and handle the outcome
|
||||||
boolean success = command.run();
|
BatchRunOutcome outcome = command.run(runContext);
|
||||||
|
|
||||||
if (success) {
|
// Mark run as completed (AP-003)
|
||||||
LOG.info("No-op startup path completed successfully.");
|
runContext.setEndInstant(Instant.now());
|
||||||
|
|
||||||
|
if (outcome.isSuccess()) {
|
||||||
|
LOG.info("Batch run completed successfully. RunId: {}", runContext.runId());
|
||||||
|
return 0;
|
||||||
|
} else if (outcome.isLockUnavailable()) {
|
||||||
|
LOG.warn("Batch run aborted: another instance is already running. RunId: {}", runContext.runId());
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
LOG.error("Batch run failed. RunId: {}", runContext.runId());
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return success ? 0 : 1;
|
|
||||||
} catch (InvalidStartConfigurationException e) {
|
} catch (InvalidStartConfigurationException e) {
|
||||||
// Controlled failure for invalid configuration - log clearly without stack trace
|
// Controlled failure for invalid configuration - log clearly without stack trace
|
||||||
LOG.error("Configuration validation failed: {}", e.getMessage());
|
LOG.error("Configuration validation failed: {}", e.getMessage());
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* Bootstrap module for application startup and technical wiring.
|
* Bootstrap module for application startup and technical object graph construction.
|
||||||
|
* <p>
|
||||||
|
* Responsibility: Orchestrate the startup flow, load configuration, validate it,
|
||||||
|
* create and wire all application components, and invoke the CLI adapter entry point.
|
||||||
|
* <p>
|
||||||
|
* Components:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.BootstrapRunner}
|
||||||
|
* — Orchestrator of startup sequence and object graph construction (M2-AP-005)</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication}
|
||||||
|
* — Main entry point that invokes BootstrapRunner (M1)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* M2 Implementation:
|
||||||
|
* <ul>
|
||||||
|
* <li>Uses factory pattern with pluggable interfaces for all ports and use cases</li>
|
||||||
|
* <li>Manually constructs object graph without framework dependencies</li>
|
||||||
|
* <li>Ensures strict inward dependency direction toward application and domain</li>
|
||||||
|
* <li>Ready for extension by later milestones via factory methods</li>
|
||||||
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* This package contains the main entry point and manual object graph construction.
|
|
||||||
* AP-003: Provides a minimal, controlled startup path without dependency injection frameworks.
|
* AP-003: Provides a minimal, controlled startup path without dependency injection frameworks.
|
||||||
|
* <p>
|
||||||
|
* AP-005: CLI adapter and complete M2 object graph wiring.
|
||||||
|
* <p>
|
||||||
|
* AP-006: Wires FilesystemRunLockPortAdapter (adapter-out) from validated config; retired temporary no-op lock.
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||||
@@ -4,8 +4,11 @@ import de.gecheckt.pdf.umbenenner.adapter.inbound.cli.SchedulerBatchCommand;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.config.InvalidStartConfigurationException;
|
import de.gecheckt.pdf.umbenenner.application.config.InvalidStartConfigurationException;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
|
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator;
|
import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -35,8 +38,9 @@ class BootstrapRunnerTest {
|
|||||||
// Create mock factories that return working components
|
// Create mock factories that return working components
|
||||||
BootstrapRunner runner = new BootstrapRunner(
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
() -> mockConfigPort,
|
() -> mockConfigPort,
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
StartConfigurationValidator::new,
|
StartConfigurationValidator::new,
|
||||||
port -> new MockRunBatchProcessingUseCase(true),
|
(port, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||||
useCase -> new SchedulerBatchCommand(useCase)
|
useCase -> new SchedulerBatchCommand(useCase)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,8 +64,9 @@ class BootstrapRunnerTest {
|
|||||||
|
|
||||||
BootstrapRunner runner = new BootstrapRunner(
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
() -> mockConfigPort,
|
() -> mockConfigPort,
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
() -> failingValidator,
|
() -> failingValidator,
|
||||||
port -> new MockRunBatchProcessingUseCase(true),
|
(port, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||||
useCase -> new SchedulerBatchCommand(useCase)
|
useCase -> new SchedulerBatchCommand(useCase)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,8 +84,9 @@ class BootstrapRunnerTest {
|
|||||||
|
|
||||||
BootstrapRunner runner = new BootstrapRunner(
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
() -> failingConfigPort,
|
() -> failingConfigPort,
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
StartConfigurationValidator::new,
|
StartConfigurationValidator::new,
|
||||||
port -> new MockRunBatchProcessingUseCase(true),
|
(port, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||||
useCase -> new SchedulerBatchCommand(useCase)
|
useCase -> new SchedulerBatchCommand(useCase)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,8 +104,9 @@ class BootstrapRunnerTest {
|
|||||||
|
|
||||||
BootstrapRunner runner = new BootstrapRunner(
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
() -> throwingConfigPort,
|
() -> throwingConfigPort,
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
StartConfigurationValidator::new,
|
StartConfigurationValidator::new,
|
||||||
port -> new MockRunBatchProcessingUseCase(true),
|
(port, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||||
useCase -> new SchedulerBatchCommand(useCase)
|
useCase -> new SchedulerBatchCommand(useCase)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,23 +116,45 @@ class BootstrapRunnerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void run_returnsOneWhenCommandReturnsFalse() throws Exception {
|
void run_returnsOneWhenBatchFails() throws Exception {
|
||||||
// Create a mock configuration port that returns valid config
|
// Create a mock configuration port that returns valid config
|
||||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||||
|
|
||||||
// Create a use case that returns false
|
// Create a use case that returns failure outcome
|
||||||
RunBatchProcessingUseCase failingUseCase = () -> false;
|
RunBatchProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE;
|
||||||
|
|
||||||
BootstrapRunner runner = new BootstrapRunner(
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
() -> mockConfigPort,
|
() -> mockConfigPort,
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
StartConfigurationValidator::new,
|
StartConfigurationValidator::new,
|
||||||
port -> failingUseCase,
|
(port, lock) -> failingUseCase,
|
||||||
useCase -> new SchedulerBatchCommand(useCase)
|
useCase -> new SchedulerBatchCommand(useCase)
|
||||||
);
|
);
|
||||||
|
|
||||||
int exitCode = runner.run();
|
int exitCode = runner.run();
|
||||||
|
|
||||||
assertEquals(1, exitCode, "Command returning false should return exit code 1");
|
assertEquals(1, exitCode, "Batch failure outcome should return exit code 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void run_returnsOneWhenLockUnavailable() throws Exception {
|
||||||
|
// AP-007: controlled early termination because another instance is already running
|
||||||
|
// maps to exit code 1 (start protection counts as a hard startup failure)
|
||||||
|
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||||
|
|
||||||
|
RunBatchProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE;
|
||||||
|
|
||||||
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
|
() -> mockConfigPort,
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
|
StartConfigurationValidator::new,
|
||||||
|
(port, lock) -> lockUnavailableUseCase,
|
||||||
|
useCase -> new SchedulerBatchCommand(useCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
int exitCode = runner.run();
|
||||||
|
|
||||||
|
assertEquals(1, exitCode, "Lock unavailable (another instance running) should return exit code 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -194,8 +223,23 @@ class BootstrapRunnerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean execute() {
|
public BatchRunOutcome execute(BatchRunContext context) {
|
||||||
return shouldSucceed;
|
return shouldSucceed ? BatchRunOutcome.SUCCESS : BatchRunOutcome.FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock RunLockPort for testing.
|
||||||
|
*/
|
||||||
|
private static class MockRunLockPort implements RunLockPort {
|
||||||
|
@Override
|
||||||
|
public void acquire() {
|
||||||
|
// Mock: no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
// Mock: no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technical context representing a single batch processing run.
|
||||||
|
* <p>
|
||||||
|
* Each batch run is assigned a unique {@link RunId} and has associated timestamp information.
|
||||||
|
* The context flows through the entire execution from Bootstrap through Use Case,
|
||||||
|
* enabling correlation of all activities and results within a single run.
|
||||||
|
* <p>
|
||||||
|
* Responsibilities:
|
||||||
|
* <ul>
|
||||||
|
* <li>Track the unique identity of the batch run</li>
|
||||||
|
* <li>Record when the run started (and eventually when it ends)</li>
|
||||||
|
* <li>Provide run context to persistence, logging, and result tracking (future milestones)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* This context is independent of individual document processing and contains
|
||||||
|
* no business logic. It is purely a technical container for run identity and timing.
|
||||||
|
*
|
||||||
|
* @since M2-AP-003
|
||||||
|
*/
|
||||||
|
public final class BatchRunContext {
|
||||||
|
|
||||||
|
private final RunId runId;
|
||||||
|
private final Instant startInstant;
|
||||||
|
private Instant endInstant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new BatchRunContext with the given run ID and start time.
|
||||||
|
* <p>
|
||||||
|
* The end instant is initially null and may be set later via {@link #setEndInstant(Instant)}.
|
||||||
|
*
|
||||||
|
* @param runId the unique identifier for this run, must not be null
|
||||||
|
* @param startInstant the moment when the run started, must not be null
|
||||||
|
* @throws NullPointerException if runId or startInstant is null
|
||||||
|
*/
|
||||||
|
public BatchRunContext(RunId runId, Instant startInstant) {
|
||||||
|
this.runId = Objects.requireNonNull(runId, "RunId must not be null");
|
||||||
|
this.startInstant = Objects.requireNonNull(startInstant, "Start instant must not be null");
|
||||||
|
this.endInstant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier of this run.
|
||||||
|
*
|
||||||
|
* @return the run ID, never null
|
||||||
|
*/
|
||||||
|
public RunId runId() {
|
||||||
|
return runId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instant when this run started.
|
||||||
|
*
|
||||||
|
* @return the start instant, never null
|
||||||
|
*/
|
||||||
|
public Instant startInstant() {
|
||||||
|
return startInstant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instant when this run ended, or null if the run has not yet completed.
|
||||||
|
* <p>
|
||||||
|
* The end instant is set by {@link #setEndInstant(Instant)} during run completion.
|
||||||
|
*
|
||||||
|
* @return the end instant, or null if the run is still in progress
|
||||||
|
*/
|
||||||
|
public Instant endInstant() {
|
||||||
|
return endInstant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the instant when this run ended.
|
||||||
|
* <p>
|
||||||
|
* This should be called once at the end of the batch run to mark completion.
|
||||||
|
* Typically called by the orchestration layer (Bootstrap) after the use case completes.
|
||||||
|
*
|
||||||
|
* @param endInstant the moment when the run completed, must not be null
|
||||||
|
* @throws NullPointerException if endInstant is null
|
||||||
|
* @throws IllegalStateException if end instant has already been set
|
||||||
|
*/
|
||||||
|
public void setEndInstant(Instant endInstant) {
|
||||||
|
Objects.requireNonNull(endInstant, "End instant must not be null");
|
||||||
|
if (this.endInstant != null) {
|
||||||
|
throw new IllegalStateException("End instant has already been set");
|
||||||
|
}
|
||||||
|
this.endInstant = endInstant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this run has completed (end instant is set).
|
||||||
|
*
|
||||||
|
* @return true if end instant is not null, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isCompleted() {
|
||||||
|
return endInstant != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "BatchRunContext{" +
|
||||||
|
"runId=" + runId +
|
||||||
|
", startInstant=" + startInstant +
|
||||||
|
", endInstant=" + endInstant +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration of all valid processing status values for a document within a batch run.
|
||||||
|
* <p>
|
||||||
|
* Each status reflects the outcome or current state of a document processing attempt.
|
||||||
|
* Status transitions follow the rules defined in the architecture specification and persist
|
||||||
|
* across multiple batch runs via the repository layer.
|
||||||
|
* <p>
|
||||||
|
* Status Categories:
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>Final Success:</strong> {@link #SUCCESS}</li>
|
||||||
|
* <li><strong>Retryable Failure:</strong> {@link #FAILED_RETRYABLE}</li>
|
||||||
|
* <li><strong>Final Failure:</strong> {@link #FAILED_FINAL}</li>
|
||||||
|
* <li><strong>Skip (Already Processed):</strong> {@link #SKIPPED_ALREADY_PROCESSED}</li>
|
||||||
|
* <li><strong>Skip (Final Failure):</strong> {@link #SKIPPED_FINAL_FAILURE}</li>
|
||||||
|
* <li><strong>Processing (Transient):</strong> {@link #PROCESSING}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since M2-AP-001
|
||||||
|
*/
|
||||||
|
public enum ProcessingStatus {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document was successfully processed and written to the target location.
|
||||||
|
* <p>
|
||||||
|
* A document with this status will be skipped in all future batch runs.
|
||||||
|
* Status is final and irreversible.
|
||||||
|
*/
|
||||||
|
SUCCESS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processing failed with a transient error (temporary infrastructure problem).
|
||||||
|
* <p>
|
||||||
|
* Examples: API timeout, temporary file lock, momentary network issue.
|
||||||
|
* <p>
|
||||||
|
* A document with this status may be retried in a later batch run, up to the
|
||||||
|
* configured maximum retry count. Retry count is tracked separately.
|
||||||
|
*/
|
||||||
|
FAILED_RETRYABLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processing failed with a deterministic content error (non-recoverable problem).
|
||||||
|
* <p>
|
||||||
|
* Examples: PDF has no extractable text, page limit exceeded, document is ambiguous.
|
||||||
|
* <p>
|
||||||
|
* A document with this status receives exactly one retry in a later batch run.
|
||||||
|
* After that retry, if it still fails, status becomes {@link #FAILED_FINAL}.
|
||||||
|
* No further retries are attempted.
|
||||||
|
*/
|
||||||
|
FAILED_FINAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document was skipped because it has already been successfully processed.
|
||||||
|
* <p>
|
||||||
|
* This is a final skip state. The document will remain skipped in all future runs.
|
||||||
|
* Document fingerprint matching ensures that identical content is never reprocessed.
|
||||||
|
*/
|
||||||
|
SKIPPED_ALREADY_PROCESSED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document was skipped because it has already failed finally.
|
||||||
|
* <p>
|
||||||
|
* This is a final skip state. The document will remain skipped in all future runs.
|
||||||
|
* No further processing attempts will be made.
|
||||||
|
*/
|
||||||
|
SKIPPED_FINAL_FAILURE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technical transient status: Document is currently being processed.
|
||||||
|
* <p>
|
||||||
|
* This status is used internally during a batch run to mark documents
|
||||||
|
* that are actively being worked on. If a batch run is interrupted,
|
||||||
|
* documents in this state will be re-attempted in the next run.
|
||||||
|
* <p>
|
||||||
|
* This status should not persist across batch runs in normal operation.
|
||||||
|
*/
|
||||||
|
PROCESSING
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique identifier for a batch run.
|
||||||
|
* <p>
|
||||||
|
* Each invocation of the PDF Umbenenner application receives a unique RunId
|
||||||
|
* that persists for the entire batch processing cycle. The RunId is used to:
|
||||||
|
* <ul>
|
||||||
|
* <li>Correlate all documents processed in a single run</li>
|
||||||
|
* <li>Track attempt history and retry decisions across runs</li>
|
||||||
|
* <li>Identify logs and audit trails for a specific execution</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* RunId is intentionally simple: a non-null, immutable string value.
|
||||||
|
* Implementations may choose UUID format, timestamp-based IDs, or sequential IDs.
|
||||||
|
* The internal structure is opaque to consumers.
|
||||||
|
*
|
||||||
|
* @since M2-AP-003
|
||||||
|
*/
|
||||||
|
public final class RunId implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new RunId with the given string value.
|
||||||
|
* <p>
|
||||||
|
* The value must be non-null and non-empty. No format validation is enforced;
|
||||||
|
* implementations are free to choose their own ID scheme.
|
||||||
|
*
|
||||||
|
* @param value the unique identifier string, must not be null or empty
|
||||||
|
* @throws NullPointerException if value is null
|
||||||
|
* @throws IllegalArgumentException if value is empty
|
||||||
|
*/
|
||||||
|
public RunId(String value) {
|
||||||
|
Objects.requireNonNull(value, "RunId value must not be null");
|
||||||
|
if (value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("RunId value must not be empty");
|
||||||
|
}
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the string representation of this RunId.
|
||||||
|
*
|
||||||
|
* @return the unique identifier value
|
||||||
|
*/
|
||||||
|
public String value() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
RunId runId = (RunId) o;
|
||||||
|
return Objects.equals(value, runId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Domain model package containing core value objects and enumerations.
|
||||||
|
* <p>
|
||||||
|
* This package contains the fundamental domain entities and status models required for document processing:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus} — enumeration of all valid document processing states</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* All classes in this package are:
|
||||||
|
* <ul>
|
||||||
|
* <li>Infrastructure-agnostic (no database, filesystem, network, or framework dependencies)</li>
|
||||||
|
* <li>Immutable value objects or enumerations</li>
|
||||||
|
* <li>Reusable across all layers via the Application and Adapter contracts</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since M2-AP-001
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Domain layer containing business entities and value objects.
|
* Domain layer containing business entities and value objects.
|
||||||
* <p>
|
* <p>
|
||||||
* This package is infrastructure-agnostic and contains no dependencies on external systems.
|
* This package is infrastructure-agnostic and contains no dependencies on external systems,
|
||||||
* AP-003: Currently empty as no domain logic has been implemented yet.
|
* databases, filesystems, HTTP clients, or any other technical infrastructure.
|
||||||
|
* <p>
|
||||||
|
* M2-AP-001 Implementation:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.domain.model} — Core domain model including
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus} enumeration</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Subpackages:
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>model:</strong> Domain entities and value objects (e.g., status enumerations)</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.domain;
|
package de.gecheckt.pdf.umbenenner.domain;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link BatchRunContext}.
|
||||||
|
* <p>
|
||||||
|
* Verifies correct modeling of batch run lifecycle including initialization,
|
||||||
|
* timestamp tracking, completion marking, and state validation.
|
||||||
|
*/
|
||||||
|
class BatchRunContextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_createsContextWithRunIdAndStartTime() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
|
||||||
|
assertNotNull(context);
|
||||||
|
assertEquals(runId, context.runId());
|
||||||
|
assertEquals(startTime, context.startInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_throwsNullPointerExceptionWhenRunIdIsNull() {
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
assertThrows(NullPointerException.class, () -> new BatchRunContext(null, startTime),
|
||||||
|
"Constructor should throw NullPointerException when RunId is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_throwsNullPointerExceptionWhenStartInstantIsNull() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
assertThrows(NullPointerException.class, () -> new BatchRunContext(runId, null),
|
||||||
|
"Constructor should throw NullPointerException when start instant is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runId_returnsTheConstructorRunId() {
|
||||||
|
RunId expectedRunId = new RunId("run-123");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
BatchRunContext context = new BatchRunContext(expectedRunId, startTime);
|
||||||
|
|
||||||
|
assertEquals(expectedRunId, context.runId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startInstant_returnsTheConstructorStartTime() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant expectedStartTime = Instant.parse("2026-03-31T20:00:00Z");
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, expectedStartTime);
|
||||||
|
|
||||||
|
assertEquals(expectedStartTime, context.startInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endInstant_isNullImmediatelyAfterConstruction() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
|
||||||
|
assertNull(context.endInstant(), "End instant should be null immediately after construction");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isCompleted_returnsFalseWhenEndInstantNotSet() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
|
||||||
|
assertFalse(context.isCompleted(), "isCompleted should return false when end instant is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setEndInstant_setsTheEndTime() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.parse("2026-03-31T20:00:00Z");
|
||||||
|
Instant endTime = Instant.parse("2026-03-31T20:01:00Z");
|
||||||
|
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
context.setEndInstant(endTime);
|
||||||
|
|
||||||
|
assertEquals(endTime, context.endInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setEndInstant_throwsNullPointerExceptionWhenEndInstantIsNull() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
|
||||||
|
assertThrows(NullPointerException.class, () -> context.setEndInstant(null),
|
||||||
|
"setEndInstant should throw NullPointerException when end instant is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setEndInstant_throwsIllegalStateExceptionWhenAlreadySet() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
Instant firstEndTime = Instant.now().plusSeconds(60);
|
||||||
|
Instant secondEndTime = Instant.now().plusSeconds(120);
|
||||||
|
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
context.setEndInstant(firstEndTime);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> context.setEndInstant(secondEndTime),
|
||||||
|
"setEndInstant should throw IllegalStateException when called a second time");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isCompleted_returnsTrueAfterSetEndInstant() {
|
||||||
|
RunId runId = new RunId("test-run");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
Instant endTime = Instant.now().plusSeconds(60);
|
||||||
|
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
context.setEndInstant(endTime);
|
||||||
|
|
||||||
|
assertTrue(context.isCompleted(), "isCompleted should return true after setting end instant");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toString_containsRunIdAndTimestamps() {
|
||||||
|
RunId runId = new RunId("test-run-001");
|
||||||
|
Instant startTime = Instant.parse("2026-03-31T20:00:00Z");
|
||||||
|
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
String representation = context.toString();
|
||||||
|
|
||||||
|
assertTrue(representation.contains("test-run-001"), "toString should contain RunId");
|
||||||
|
assertTrue(representation.contains("2026-03-31"), "toString should contain timestamp information");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLifecycle_startToCompletion() {
|
||||||
|
RunId runId = new RunId("test-run-lifecycle");
|
||||||
|
Instant startTime = Instant.parse("2026-03-31T20:00:00Z");
|
||||||
|
Instant endTime = Instant.parse("2026-03-31T20:05:00Z");
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
BatchRunContext context = new BatchRunContext(runId, startTime);
|
||||||
|
assertFalse(context.isCompleted());
|
||||||
|
|
||||||
|
// Complete context
|
||||||
|
context.setEndInstant(endTime);
|
||||||
|
assertTrue(context.isCompleted());
|
||||||
|
|
||||||
|
// Verify all data
|
||||||
|
assertEquals(runId, context.runId());
|
||||||
|
assertEquals(startTime, context.startInstant());
|
||||||
|
assertEquals(endTime, context.endInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleContextsAreIndependent() {
|
||||||
|
RunId runId1 = new RunId("run-1");
|
||||||
|
RunId runId2 = new RunId("run-2");
|
||||||
|
Instant startTime1 = Instant.parse("2026-03-31T20:00:00Z");
|
||||||
|
Instant startTime2 = Instant.parse("2026-03-31T21:00:00Z");
|
||||||
|
|
||||||
|
BatchRunContext context1 = new BatchRunContext(runId1, startTime1);
|
||||||
|
BatchRunContext context2 = new BatchRunContext(runId2, startTime2);
|
||||||
|
|
||||||
|
Instant endTime1 = Instant.parse("2026-03-31T20:05:00Z");
|
||||||
|
context1.setEndInstant(endTime1);
|
||||||
|
|
||||||
|
assertTrue(context1.isCompleted());
|
||||||
|
assertFalse(context2.isCompleted(), "Context2 should not be affected by Context1's completion");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link ProcessingStatus} enumeration.
|
||||||
|
* <p>
|
||||||
|
* Verifies that all required status values are present and correctly defined
|
||||||
|
* for M2 and future milestones.
|
||||||
|
*/
|
||||||
|
class ProcessingStatusTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allRequiredStatusValuesExist() {
|
||||||
|
// Verify all status values required by the architecture are present
|
||||||
|
assertNotNull(ProcessingStatus.SUCCESS);
|
||||||
|
assertNotNull(ProcessingStatus.FAILED_RETRYABLE);
|
||||||
|
assertNotNull(ProcessingStatus.FAILED_FINAL);
|
||||||
|
assertNotNull(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
|
||||||
|
assertNotNull(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
||||||
|
assertNotNull(ProcessingStatus.PROCESSING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successStatus_isDefinedAndAccessible() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.SUCCESS;
|
||||||
|
assertEquals(ProcessingStatus.SUCCESS, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedRetryableStatus_isDefinedAndAccessible() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.FAILED_RETRYABLE;
|
||||||
|
assertEquals(ProcessingStatus.FAILED_RETRYABLE, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedFinalStatus_isDefinedAndAccessible() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.FAILED_FINAL;
|
||||||
|
assertEquals(ProcessingStatus.FAILED_FINAL, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skippedAlreadyProcessedStatus_isDefinedAndAccessible() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.SKIPPED_ALREADY_PROCESSED;
|
||||||
|
assertEquals(ProcessingStatus.SKIPPED_ALREADY_PROCESSED, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skippedFinalFailureStatus_isDefinedAndAccessible() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.SKIPPED_FINAL_FAILURE;
|
||||||
|
assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processingStatus_isDefinedAndAccessible() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.PROCESSING;
|
||||||
|
assertEquals(ProcessingStatus.PROCESSING, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusEquality_worksByReference() {
|
||||||
|
// Enums have identity-based equality
|
||||||
|
assertTrue(ProcessingStatus.SUCCESS == ProcessingStatus.SUCCESS);
|
||||||
|
assertFalse(ProcessingStatus.SUCCESS == ProcessingStatus.FAILED_FINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusCanBeUsedInSwitch() {
|
||||||
|
ProcessingStatus status = ProcessingStatus.FAILED_RETRYABLE;
|
||||||
|
String result = "";
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case SUCCESS -> result = "success";
|
||||||
|
case FAILED_RETRYABLE -> result = "retryable";
|
||||||
|
case FAILED_FINAL -> result = "final";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> result = "skip-processed";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> result = "skip-failed";
|
||||||
|
case PROCESSING -> result = "processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("retryable", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusValues_areSixInTotal() {
|
||||||
|
ProcessingStatus[] values = ProcessingStatus.values();
|
||||||
|
assertEquals(6, values.length, "ProcessingStatus should have exactly 6 values");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link RunId} value object.
|
||||||
|
* <p>
|
||||||
|
* Verifies immutability, value semantics, validation, and correct behavior
|
||||||
|
* as a unique identifier for batch runs.
|
||||||
|
*/
|
||||||
|
class RunIdTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_createsRunIdWithValidValue() {
|
||||||
|
RunId runId = new RunId("test-run-123");
|
||||||
|
assertNotNull(runId);
|
||||||
|
assertEquals("test-run-123", runId.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_throwsNullPointerExceptionWhenValueIsNull() {
|
||||||
|
assertThrows(NullPointerException.class, () -> new RunId(null),
|
||||||
|
"Constructor should throw NullPointerException for null value");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_throwsIllegalArgumentExceptionWhenValueIsEmpty() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RunId(""),
|
||||||
|
"Constructor should throw IllegalArgumentException for empty value");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_throwsIllegalArgumentExceptionWhenValueIsOnlyWhitespace() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RunId(" "),
|
||||||
|
"Constructor should throw IllegalArgumentException for whitespace-only value");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void value_returnsTheConstructorValue() {
|
||||||
|
String expectedValue = "run-id-456";
|
||||||
|
RunId runId = new RunId(expectedValue);
|
||||||
|
assertEquals(expectedValue, runId.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equals_returnsTrueForIdenticalValues() {
|
||||||
|
RunId runId1 = new RunId("same-id");
|
||||||
|
RunId runId2 = new RunId("same-id");
|
||||||
|
assertEquals(runId1, runId2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equals_returnsFalseForDifferentValues() {
|
||||||
|
RunId runId1 = new RunId("id-1");
|
||||||
|
RunId runId2 = new RunId("id-2");
|
||||||
|
assertNotEquals(runId1, runId2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equals_returnsFalseWhenComparedWithNull() {
|
||||||
|
RunId runId = new RunId("test-id");
|
||||||
|
assertNotEquals(runId, null);
|
||||||
|
assertFalse(runId.equals(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equals_returnsFalseWhenComparedWithDifferentType() {
|
||||||
|
RunId runId = new RunId("test-id");
|
||||||
|
assertNotEquals(runId, "test-id");
|
||||||
|
assertFalse(runId.equals("test-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hashCode_isSameForIdenticalValues() {
|
||||||
|
RunId runId1 = new RunId("same-id");
|
||||||
|
RunId runId2 = new RunId("same-id");
|
||||||
|
assertEquals(runId1.hashCode(), runId2.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hashCode_isDifferentForDifferentValues() {
|
||||||
|
RunId runId1 = new RunId("id-1");
|
||||||
|
RunId runId2 = new RunId("id-2");
|
||||||
|
// Note: this is not guaranteed for different values, but likely
|
||||||
|
assertNotEquals(runId1.hashCode(), runId2.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toString_returnsTheValue() {
|
||||||
|
String value = "run-id-789";
|
||||||
|
RunId runId = new RunId(value);
|
||||||
|
assertEquals(value, runId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runIdCanBeUsedInCollections() {
|
||||||
|
RunId runId1 = new RunId("id-1");
|
||||||
|
RunId runId2 = new RunId("id-1");
|
||||||
|
RunId runId3 = new RunId("id-2");
|
||||||
|
|
||||||
|
var set = new java.util.HashSet<RunId>();
|
||||||
|
set.add(runId1);
|
||||||
|
set.add(runId2); // Should not add duplicate
|
||||||
|
set.add(runId3);
|
||||||
|
|
||||||
|
// runId1 and runId2 are equal, so set should have 2 elements
|
||||||
|
assertEquals(2, set.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runIdCanBeMapKey() {
|
||||||
|
RunId runId1 = new RunId("id-1");
|
||||||
|
RunId runId2 = new RunId("id-1");
|
||||||
|
|
||||||
|
var map = new java.util.HashMap<RunId, String>();
|
||||||
|
map.put(runId1, "first");
|
||||||
|
map.put(runId2, "second");
|
||||||
|
|
||||||
|
// runId1 and runId2 are equal, so second put should overwrite
|
||||||
|
assertEquals(1, map.size());
|
||||||
|
assertEquals("second", map.get(runId1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_acceptsUuidFormatValue() {
|
||||||
|
String uuidValue = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
|
RunId runId = new RunId(uuidValue);
|
||||||
|
assertEquals(uuidValue, runId.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_acceptsTimestampFormatValue() {
|
||||||
|
String timestampValue = "2026-03-31T21:41:48.000Z";
|
||||||
|
RunId runId = new RunId(timestampValue);
|
||||||
|
assertEquals(timestampValue, runId.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_acceptsSequentialNumberValue() {
|
||||||
|
String sequentialValue = "00001";
|
||||||
|
RunId runId = new RunId(sequentialValue);
|
||||||
|
assertEquals(sequentialValue, runId.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user