From 9d66a446b3233e16b8907f9cb565da2ac0392620 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 31 Mar 2026 21:52:48 +0200 Subject: [PATCH] =?UTF-8?q?Implementierung=20f=C3=BCr=20M2=20vorl=C3=A4ufi?= =?UTF-8?q?g=20abgeschlossen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 6 +- .../inbound/cli/SchedulerBatchCommand.java | 29 ++- .../adapter/inbound/cli/package-info.java | 21 +- .../cli/SchedulerBatchCommandTest.java | 154 ++++++++++++++ .../lock/FilesystemRunLockPortAdapter.java | 82 ++++++++ .../adapter/outbound/lock/package-info.java | 14 ++ .../FilesystemRunLockPortAdapterTest.java | 122 ++++++++++++ .../application/port/in/BatchRunOutcome.java | 106 ++++++++++ .../port/in/RunBatchProcessingUseCase.java | 43 +++- .../application/port/in/package-info.java | 26 ++- .../application/port/out/ClockPort.java | 34 ++++ .../application/port/out/RunLockPort.java | 58 ++++++ .../port/out/RunLockUnavailableException.java | 35 ++++ .../application/port/out/package-info.java | 30 ++- .../usecase/M2BatchRunProcessingUseCase.java | 104 ++++++++++ .../NoOpRunBatchProcessingUseCase.java | 15 +- .../application/usecase/package-info.java | 11 +- .../M2BatchRunProcessingUseCaseTest.java | 188 ++++++++++++++++++ .../umbenenner/bootstrap/BootstrapRunner.java | 67 +++++-- .../umbenenner/bootstrap/package-info.java | 26 ++- .../bootstrap/BootstrapRunnerTest.java | 66 +++++- .../domain/model/BatchRunContext.java | 110 ++++++++++ .../domain/model/ProcessingStatus.java | 79 ++++++++ .../pdf/umbenenner/domain/model/RunId.java | 73 +++++++ .../umbenenner/domain/model/package-info.java | 18 ++ .../pdf/umbenenner/domain/package-info.java | 15 +- .../domain/model/BatchRunContextTest.java | 175 ++++++++++++++++ .../domain/model/ProcessingStatusTest.java | 91 +++++++++ .../umbenenner/domain/model/RunIdTest.java | 146 ++++++++++++++ 29 files changed, 1892 insertions(+), 52 deletions(-) create mode 100644 pdf-umbenenner-adapter-in-cli/src/test/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommandTest.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapter.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/package-info.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapterTest.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunOutcome.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ClockPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockUnavailableException.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java create mode 100644 pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContext.java create mode 100644 pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java create mode 100644 pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/RunId.java create mode 100644 pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/package-info.java create mode 100644 pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContextTest.java create mode 100644 pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatusTest.java create mode 100644 pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/RunIdTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 82fd7f0..377d543 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,11 @@ "permissions": { "allow": [ "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)" ] } } diff --git a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java index fe8c2af..d88a827 100644 --- a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java +++ b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java @@ -1,12 +1,28 @@ 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; /** * CLI command adapter for batch processing scheduling. *

- * 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. + *

* AP-003 Implementation: Minimal no-op command to validate the call chain from CLI to Application. + *

+ * M2-AP-002 Update: Returns {@link BatchRunOutcome} instead of boolean, + * allowing Bootstrap to systematically derive exit codes (AP-007). + *

+ * M2-AP-003 Update: Accepts {@link BatchRunContext} and passes it to the use case, + * enabling run ID and timing tracking throughout the batch cycle. + *

+ * 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 { @@ -22,13 +38,14 @@ public class SchedulerBatchCommand { } /** - * Executes the batch processing command. + * Executes the batch processing command with the given run context. *

- * 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() { - return useCase.execute(); + public BatchRunOutcome run(BatchRunContext context) { + return useCase.execute(context); } } \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java index 35a6ad4..9ccf8c2 100644 --- a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java +++ b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java @@ -1,7 +1,22 @@ /** - * CLI adapter for inbound commands. - * This package contains the technical entry points that delegate to application use cases. + * Inbound adapter for CLI/batch command processing. *

- * 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. + *

+ * Components: + *

+ *

+ * M2-AP-005 Architecture: + *

*/ package de.gecheckt.pdf.umbenenner.adapter.inbound.cli; \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/test/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommandTest.java b/pdf-umbenenner-adapter-in-cli/src/test/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommandTest.java new file mode 100644 index 0000000..cfe21bc --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/src/test/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommandTest.java @@ -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}. + *

+ * 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; + } + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapter.java new file mode 100644 index 0000000..c7d93b3 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapter.java @@ -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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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); + } + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/package-info.java new file mode 100644 index 0000000..0b5712a --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/package-info.java @@ -0,0 +1,14 @@ +/** + * Outbound adapter for run lock management. + *

+ * Components: + *

+ *

+ * 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; diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapterTest.java new file mode 100644 index 0000000..d7a3047 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/lock/FilesystemRunLockPortAdapterTest.java @@ -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}. + *

+ * 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"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunOutcome.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunOutcome.java new file mode 100644 index 0000000..5ab0d71 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunOutcome.java @@ -0,0 +1,106 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +/** + * Value object representing the outcome of a batch processing run. + *

+ * 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. + *

+ * The outcome is independent of individual document processing results; + * it represents the batch operation itself (lock acquired, no critical startup failure, etc.). + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * Maps to exit code 0. + */ + SUCCESS("Batch processing completed successfully"), + + /** + * Batch run aborted because another instance is already running. + *

+ * 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. + *

+ * 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. + *

+ * The run encountered a critical error that prevented normal batch completion, + * such as an unrecoverable I/O error or infrastructure failure. + *

+ * 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. + *

+ * 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; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java index 7d3f74b..9533ee1 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java @@ -1,18 +1,51 @@ package de.gecheckt.pdf.umbenenner.application.port.in; +import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; + /** * Inbound port for batch processing execution. - * This interface defines the contract for triggering batch operations. *

- * 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. + *

+ * Responsibilities: + *

+ *

+ * 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). + *

+ * M2-AP-002 Implementation: + *

+ *

+ * M2-AP-003 Update: + *

*/ public interface RunBatchProcessingUseCase { + /** * Executes the batch processing workflow. *

- * 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. + *

+ * 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); } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java index 43313f4..af1b1f5 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java @@ -1,5 +1,25 @@ /** - * Inbound ports (application service interfaces) for the hexagonal architecture. - * Use cases in this package are invoked by adapters from the outside world. + * Inbound ports (use cases) for the application. + *

+ * Inbound ports define the contracts for interacting with the application from external adapters. + * All calls flow FROM adapters INTO the application through these ports. + *

+ * Central inbound port: + *

+ *

+ * Return models: + *

+ *

+ * 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; \ No newline at end of file +package de.gecheckt.pdf.umbenenner.application.port.in; diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ClockPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ClockPort.java new file mode 100644 index 0000000..42aa0d2 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ClockPort.java @@ -0,0 +1,34 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import java.time.Instant; + +/** + * Outbound port for system time access. + *

+ * This port abstracts access to the system clock, enabling the batch run to: + *

+ *

+ * By isolating time access behind a port, the application can be tested with + * deterministic time values without requiring system clock manipulation. + *

+ * 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. + *

+ * 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(); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java new file mode 100644 index 0000000..097d356 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java @@ -0,0 +1,58 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Outbound port for exclusive run locking. + *

+ * 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). + *

+ * Responsibilities: + *

+ *

+ * Lock Lifecycle: + *

+ *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * This method is called after batch processing completes (successfully or not) + * to allow other instances to run. + *

+ * 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(); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockUnavailableException.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockUnavailableException.java new file mode 100644 index 0000000..588bf8d --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockUnavailableException.java @@ -0,0 +1,35 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Exception thrown when the run lock cannot be acquired. + *

+ * 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. + *

+ * 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); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java index f98a407..08254df 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java @@ -1,5 +1,29 @@ /** - * Outbound ports for the application layer. - * Defines interfaces for infrastructure access from the application layer. + * Outbound ports (interfaces) for the application. + *

+ * 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. + *

+ * M2-AP-002 ports: + *

+ *

+ * Exception types: + *

+ *

+ * 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; \ No newline at end of file +package de.gecheckt.pdf.umbenenner.application.port.out; diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java new file mode 100644 index 0000000..713c988 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java @@ -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}. + *

+ * This use case orchestrates the batch processing workflow with start protection + * and controlled execution lifecycle, but without actual document processing. + *

+ * Responsibilities: + *

+ *

+ * M2 Non-Goals (not implemented): + *

+ * + * @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); + } + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java index f516918..89f4c20 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java @@ -1,8 +1,10 @@ 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.domain.model.BatchRunContext; import org.apache.logging.log4j.LogManager; 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. *

* AP-005: Accepts {@link ConfigurationPort} to load typed startup configuration. + *

+ * M2-AP-002 Update: Returns {@link BatchRunOutcome} instead of boolean, + * enabling structured result handling by Bootstrap and CLI layers. + *

+ * M2-AP-003 Update: Accepts {@link BatchRunContext} to enable run ID and timing tracking. */ public class NoOpRunBatchProcessingUseCase implements RunBatchProcessingUseCase { @@ -30,12 +37,16 @@ public class NoOpRunBatchProcessingUseCase implements RunBatchProcessingUseCase } @Override - public boolean execute() { + public BatchRunOutcome execute(BatchRunContext context) { // AP-005: Load configuration through the port (technical loading only) StartConfiguration config = configurationPort.loadConfiguration(); 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 - return true; + // M2-AP-002: Return structured outcome instead of boolean + return BatchRunOutcome.SUCCESS; } } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java index 783813b..ce885f0 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java @@ -1,5 +1,14 @@ /** * Use case implementations that realize the inbound port contracts. - * Currently contains minimal no-op implementations for AP-003 technical validation. + *

+ * Implementations: + *

+ *

+ * All implementations are infrastructure-agnostic and interact only through ports. */ package de.gecheckt.pdf.umbenenner.application.usecase; \ No newline at end of file diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java new file mode 100644 index 0000000..47348bc --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java @@ -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}. + *

+ * 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; + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 870487b..13468f2 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -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.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.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.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. @@ -17,7 +26,7 @@ import de.gecheckt.pdf.umbenenner.application.usecase.NoOpRunBatchProcessingUseC * AP-003 Implementation: Creates all required components using plain Java constructor injection * and executes the minimal no-op batch processing path. *

- * AP-005: Integrates configuration loading via PropertiesConfigurationPortAdapter. + * AP-005: CLI adapter and bootstrap wiring for M2 batch orchestration with run lock integration. *

* 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 final ConfigurationPortFactory configPortFactory; + private final RunLockPortFactory runLockPortFactory; private final ValidatorFactory validatorFactory; private final UseCaseFactory useCaseFactory; private final CommandFactory commandFactory; @@ -38,6 +48,14 @@ public class BootstrapRunner { 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. */ @@ -51,7 +69,7 @@ public class BootstrapRunner { */ @FunctionalInterface 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. + *

+ * AP-006: Uses FilesystemRunLockPortAdapter for file-based exclusive run locking. */ public BootstrapRunner() { this.configPortFactory = PropertiesConfigurationPortAdapter::new; + this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.validatorFactory = StartConfigurationValidator::new; - this.useCaseFactory = NoOpRunBatchProcessingUseCase::new; + this.useCaseFactory = M2BatchRunProcessingUseCase::new; this.commandFactory = SchedulerBatchCommand::new; } @@ -76,15 +97,18 @@ public class BootstrapRunner { * Creates the BootstrapRunner with custom factories for testing. * * @param configPortFactory factory for creating ConfigurationPort instances + * @param runLockPortFactory factory for creating RunLockPort instances * @param validatorFactory factory for creating StartConfigurationValidator instances * @param useCaseFactory factory for creating RunBatchProcessingUseCase instances * @param commandFactory factory for creating SchedulerBatchCommand instances */ public BootstrapRunner(ConfigurationPortFactory configPortFactory, + RunLockPortFactory runLockPortFactory, ValidatorFactory validatorFactory, UseCaseFactory useCaseFactory, CommandFactory commandFactory) { this.configPortFactory = configPortFactory; + this.runLockPortFactory = runLockPortFactory; this.validatorFactory = validatorFactory; this.useCaseFactory = useCaseFactory; this.commandFactory = commandFactory; @@ -112,20 +136,37 @@ public class BootstrapRunner { StartConfigurationValidator validator = validatorFactory.create(); validator.validate(config); - // Step 4: Create the use case with the configuration port (application layer) - RunBatchProcessingUseCase useCase = useCaseFactory.create(configPort); + // Step 4: Create the run lock port from the validated config (AP-006) + 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); - // Step 6: Execute the command - boolean success = command.run(); + // Step 8: Execute the command with the run context and handle the outcome + BatchRunOutcome outcome = command.run(runContext); - if (success) { - LOG.info("No-op startup path completed successfully."); + // Mark run as completed (AP-003) + 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) { // Controlled failure for invalid configuration - log clearly without stack trace LOG.error("Configuration validation failed: {}", e.getMessage()); diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java index 81a0f59..e9c23f9 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java @@ -1,7 +1,29 @@ /** - * Bootstrap module for application startup and technical wiring. + * Bootstrap module for application startup and technical object graph construction. + *

+ * Responsibility: Orchestrate the startup flow, load configuration, validate it, + * create and wire all application components, and invoke the CLI adapter entry point. + *

+ * Components: + *

+ *

+ * M2 Implementation: + *

*

- * 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-005: CLI adapter and complete M2 object graph wiring. + *

+ * AP-006: Wires FilesystemRunLockPortAdapter (adapter-out) from validated config; retired temporary no-op lock. */ package de.gecheckt.pdf.umbenenner.bootstrap; \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java index 237eab1..3732350 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java @@ -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.StartConfiguration; 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.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.io.TempDir; @@ -35,8 +38,9 @@ class BootstrapRunnerTest { // Create mock factories that return working components BootstrapRunner runner = new BootstrapRunner( () -> mockConfigPort, + lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, - port -> new MockRunBatchProcessingUseCase(true), + (port, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -60,8 +64,9 @@ class BootstrapRunnerTest { BootstrapRunner runner = new BootstrapRunner( () -> mockConfigPort, + lockFile -> new MockRunLockPort(), () -> failingValidator, - port -> new MockRunBatchProcessingUseCase(true), + (port, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -79,8 +84,9 @@ class BootstrapRunnerTest { BootstrapRunner runner = new BootstrapRunner( () -> failingConfigPort, + lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, - port -> new MockRunBatchProcessingUseCase(true), + (port, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -98,8 +104,9 @@ class BootstrapRunnerTest { BootstrapRunner runner = new BootstrapRunner( () -> throwingConfigPort, + lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, - port -> new MockRunBatchProcessingUseCase(true), + (port, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -109,23 +116,45 @@ class BootstrapRunnerTest { } @Test - void run_returnsOneWhenCommandReturnsFalse() throws Exception { + void run_returnsOneWhenBatchFails() throws Exception { // Create a mock configuration port that returns valid config ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); - // Create a use case that returns false - RunBatchProcessingUseCase failingUseCase = () -> false; + // Create a use case that returns failure outcome + RunBatchProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE; BootstrapRunner runner = new BootstrapRunner( () -> mockConfigPort, + lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, - port -> failingUseCase, + (port, lock) -> failingUseCase, useCase -> new SchedulerBatchCommand(useCase) ); 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 @@ -194,8 +223,23 @@ class BootstrapRunnerTest { } @Override - public boolean execute() { - return shouldSucceed; + public BatchRunOutcome execute(BatchRunContext context) { + 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 } } } \ No newline at end of file diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContext.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContext.java new file mode 100644 index 0000000..c5cafc0 --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContext.java @@ -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. + *

+ * 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. + *

+ * Responsibilities: + *

+ *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 + + '}'; + } +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java new file mode 100644 index 0000000..d3c7088 --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java @@ -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. + *

+ * 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. + *

+ * Status Categories: + *

+ * + * @since M2-AP-001 + */ +public enum ProcessingStatus { + + /** + * Document was successfully processed and written to the target location. + *

+ * 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). + *

+ * Examples: API timeout, temporary file lock, momentary network issue. + *

+ * 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). + *

+ * Examples: PDF has no extractable text, page limit exceeded, document is ambiguous. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * This status should not persist across batch runs in normal operation. + */ + PROCESSING +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/RunId.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/RunId.java new file mode 100644 index 0000000..a8ad94e --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/RunId.java @@ -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. + *

+ * Each invocation of the PDF Umbenenner application receives a unique RunId + * that persists for the entire batch processing cycle. The RunId is used to: + *

+ *

+ * 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. + *

+ * 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; + } +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/package-info.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/package-info.java new file mode 100644 index 0000000..2746a19 --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/package-info.java @@ -0,0 +1,18 @@ +/** + * Domain model package containing core value objects and enumerations. + *

+ * This package contains the fundamental domain entities and status models required for document processing: + *

+ *

+ * All classes in this package are: + *

+ * + * @since M2-AP-001 + */ +package de.gecheckt.pdf.umbenenner.domain.model; diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java index 9d8192a..1002385 100644 --- a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java @@ -1,7 +1,18 @@ /** * Domain layer containing business entities and value objects. *

- * This package is infrastructure-agnostic and contains no dependencies on external systems. - * AP-003: Currently empty as no domain logic has been implemented yet. + * This package is infrastructure-agnostic and contains no dependencies on external systems, + * databases, filesystems, HTTP clients, or any other technical infrastructure. + *

+ * M2-AP-001 Implementation: + *

+ *

+ * Subpackages: + *

*/ package de.gecheckt.pdf.umbenenner.domain; \ No newline at end of file diff --git a/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContextTest.java b/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContextTest.java new file mode 100644 index 0000000..70075ea --- /dev/null +++ b/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/BatchRunContextTest.java @@ -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}. + *

+ * 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"); + } +} diff --git a/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatusTest.java b/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatusTest.java new file mode 100644 index 0000000..8c690c4 --- /dev/null +++ b/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatusTest.java @@ -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. + *

+ * 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"); + } +} diff --git a/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/RunIdTest.java b/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/RunIdTest.java new file mode 100644 index 0000000..06bcf7b --- /dev/null +++ b/pdf-umbenenner-domain/src/test/java/de/gecheckt/pdf/umbenenner/domain/model/RunIdTest.java @@ -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. + *

+ * 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(); + 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(); + 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()); + } +}