1
0

Implementierung für M2 vorläufig abgeschlossen

This commit is contained in:
2026-03-31 21:52:48 +02:00
parent 301f1acf08
commit 9d66a446b3
29 changed files with 1892 additions and 52 deletions

View File

@@ -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)"
]
}
}

View File

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

View File

@@ -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.
* <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;

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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.
* <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 {
/**
* Executes the batch processing workflow.
* <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);
}

View File

@@ -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.
* <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;

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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.
* <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;

View File

@@ -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);
}
}
}
}

View File

@@ -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.
* <p>
* 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 {
@@ -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;
}
}

View File

@@ -1,5 +1,14 @@
/**
* 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;

View File

@@ -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;
}
}
}

View File

@@ -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.
* <p>
* AP-005: Integrates configuration loading via PropertiesConfigurationPortAdapter.
* AP-005: CLI adapter and bootstrap wiring for M2 batch orchestration with run lock integration.
* <p>
* 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.
* <p>
* 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());

View File

@@ -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>
* This package contains the main entry point and manual object graph construction.
* 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;

View File

@@ -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
}
}
}

View File

@@ -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 +
'}';
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -1,7 +1,18 @@
/**
* Domain layer containing business entities and value objects.
* <p>
* 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.
* <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;

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

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