1
0

M1 Vollständiger Grundstand mit Build, Konfiguration, Tests und Smoke-Tests

This commit is contained in:
2026-03-31 14:04:47 +02:00
commit ea83f8fa8c
52 changed files with 2819 additions and 0 deletions

73
.gitignore vendored Normal file
View File

@@ -0,0 +1,73 @@
# =========================================================
# Build-Artefakte (Maven)
# =========================================================
**/target/
dependency-reduced-pom.xml
# =========================================================
# Eclipse / IDE
# Gilt für Parent und alle Module
# =========================================================
**/.project
**/.classpath
**/.settings/
.metadata/
# APT / Eclipse Build
**/.apt_generated/
**/.factorypath
# =========================================================
# Weitere IDEs / Editoren
# =========================================================
.idea/
.vscode/
**/*.iml
**/*.ipr
**/*.iws
# =========================================================
# Laufzeit-Konfiguration
# Diese Datei bleibt lokal und wird nicht eingecheckt.
# Beispiel-Dateien wie
# - config/application-local.example.properties
# - config/application-test.example.properties
# sollen weiterhin eingecheckt werden.
# =========================================================
config/application.properties
# =========================================================
# Laufzeitdaten / lokale Arbeitsverzeichnisse
# =========================================================
work/
logs/
data/
# =========================================================
# Lokale Datenbanken / Lock-/PID-/Log-Dateien
# =========================================================
*.db
*.sqlite
*.sqlite3
*.log
*.lck
*.lock
*.pid
# =========================================================
# Temporäre Dateien / OS-Dateien
# =========================================================
*.tmp
*.bak
*.swp
*.swo
*~
.DS_Store
Thumbs.db
Desktop.ini
# =========================================================
# JVM Crash Logs
# =========================================================
hs_err_pid*
replay_pid*

View File

@@ -0,0 +1,21 @@
# PDF Umbenenner Local Configuration Example
# AP-005: Copy this file to config/application.properties and adjust values for local development
# Mandatory M1 properties
source.folder=./work/local/source
target.folder=./work/local/target
sqlite.file=./work/local/pdf-umbenenner.db
api.baseUrl=http://localhost:8080/api
api.model=gpt-4o-mini
api.timeoutSeconds=30
max.retries.transient=3
max.pages=10
max.text.characters=5000
prompt.template.file=./config/prompts/local-template.txt
# Optional properties
runtime.lock.file=./work/local/lock.pid
log.directory=./work/local/logs
log.level=INFO
# api.key can also be set via environment variable PDF_UMBENENNER_API_KEY
api.key=your-local-api-key-here

View File

@@ -0,0 +1,21 @@
# PDF Umbenenner Test Configuration Example
# AP-005: Copy this file to config/application.properties and adjust values for testing
# Mandatory M1 properties
source.folder=./work/test/source
target.folder=./work/test/target
sqlite.file=./work/test/pdf-umbenenner-test.db
api.baseUrl=http://localhost:8081/api
api.model=gpt-4o-mini-test
api.timeoutSeconds=10
max.retries.transient=1
max.pages=5
max.text.characters=2000
prompt.template.file=./config/prompts/test-template.txt
# Optional properties
runtime.lock.file=./work/test/lock.pid
log.directory=./work/test/logs
log.level=DEBUG
# api.key can also be set via environment variable PDF_UMBENENNER_API_KEY
api.key=test-api-key-placeholder

View File

@@ -0,0 +1 @@
This is a test prompt template for AP-006 validation.

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Internal dependencies -->
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
/**
* CLI command adapter for batch processing scheduling.
* <p>
* This class acts as the technical entry point that delegates to the application layer.
* AP-003 Implementation: Minimal no-op command to validate the call chain from CLI to Application.
*/
public class SchedulerBatchCommand {
private final RunBatchProcessingUseCase useCase;
/**
* Creates a new SchedulerBatchCommand with the given use case.
*
* @param useCase the batch processing use case to delegate to
*/
public SchedulerBatchCommand(RunBatchProcessingUseCase useCase) {
this.useCase = useCase;
}
/**
* Executes the batch processing command.
* <p>
* AP-003: Delegates to the use case without any additional logic.
*
* @return true if execution succeeded, false otherwise
*/
public boolean run() {
return useCase.execute();
}
}

View File

@@ -0,0 +1,7 @@
/**
* CLI adapter for inbound commands.
* This package contains the technical entry points that delegate to application use cases.
* <p>
* AP-003: Contains minimal command classes for validating the startup path.
*/
package de.gecheckt.pdf.umbenenner.adapter.inbound.cli;

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>pdf-umbenenner-adapter-out</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Internal dependencies -->
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Infrastructure dependencies -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,165 @@
package de.gecheckt.pdf.umbenenner.adapter.outbound.configuration;
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Properties;
import java.util.function.Function;
/**
* Properties-based implementation of {@link ConfigurationPort}.
* AP-005: Loads configuration from config/application.properties with environment variable precedence.
*/
public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
private static final Logger LOG = LogManager.getLogger(PropertiesConfigurationPortAdapter.class);
private static final String DEFAULT_CONFIG_FILE_PATH = "config/application.properties";
private static final String API_KEY_ENV_VAR = "PDF_UMBENENNER_API_KEY";
private final Function<String, String> environmentLookup;
private final Path configFilePath;
/**
* Creates the adapter with default system environment lookup and default config file path.
*/
public PropertiesConfigurationPortAdapter() {
this(System::getenv, Paths.get(DEFAULT_CONFIG_FILE_PATH));
}
/**
* Creates the adapter with a custom environment lookup function.
* <p>
* This constructor is primarily intended for testing purposes to allow deterministic
* control over environment variable values without modifying the real process environment.
*
* @param environmentLookup a function that looks up environment variables by name
*/
public PropertiesConfigurationPortAdapter(Function<String, String> environmentLookup) {
this(environmentLookup, Paths.get(DEFAULT_CONFIG_FILE_PATH));
}
/**
* Creates the adapter with a custom config file path.
* <p>
* This constructor is primarily intended for testing purposes to allow loading
* configuration from arbitrary temporary files.
*
* @param configFilePath the path to the configuration properties file
*/
public PropertiesConfigurationPortAdapter(Path configFilePath) {
this(System::getenv, configFilePath);
}
/**
* Creates the adapter with custom environment lookup and config file path.
* <p>
* This constructor is primarily intended for testing purposes.
*
* @param environmentLookup a function that looks up environment variables by name
* @param configFilePath the path to the configuration properties file
*/
public PropertiesConfigurationPortAdapter(Function<String, String> environmentLookup, Path configFilePath) {
this.environmentLookup = environmentLookup;
this.configFilePath = configFilePath;
}
@Override
public StartConfiguration loadConfiguration() {
Properties props = new Properties();
try {
// Check if file exists first to preserve FileNotFoundException behavior for tests
if (!Files.exists(configFilePath)) {
throw new java.io.FileNotFoundException("Config file not found: " + configFilePath);
}
// Read file content as string to avoid escape sequence interpretation issues
String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
// Escape backslashes to prevent Java Properties from interpreting them as escape sequences
// This is needed because Windows paths use backslashes (e.g., C:\temp\...)
// and Java Properties interprets \t as tab, \n as newline, etc.
String escapedContent = content.replace("\\", "\\\\");
props.load(new StringReader(escapedContent));
} catch (IOException e) {
throw new RuntimeException("Failed to load configuration from " + configFilePath, e);
}
// Apply environment variable precedence for api.key
String apiKey = getApiKey(props);
return new StartConfiguration(
Paths.get(getRequiredProperty(props, "source.folder")),
Paths.get(getRequiredProperty(props, "target.folder")),
Paths.get(getRequiredProperty(props, "sqlite.file")),
parseUri(getRequiredProperty(props, "api.baseUrl")),
getRequiredProperty(props, "api.model"),
parseInt(getRequiredProperty(props, "api.timeoutSeconds")),
parseInt(getRequiredProperty(props, "max.retries.transient")),
parseInt(getRequiredProperty(props, "max.pages")),
parseInt(getRequiredProperty(props, "max.text.characters")),
Paths.get(getRequiredProperty(props, "prompt.template.file")),
Paths.get(getOptionalProperty(props, "runtime.lock.file", "")),
Paths.get(getOptionalProperty(props, "log.directory", "")),
getOptionalProperty(props, "log.level", "INFO"),
apiKey
);
}
private String getApiKey(Properties props) {
String envApiKey = environmentLookup.apply(API_KEY_ENV_VAR);
if (envApiKey != null && !envApiKey.isBlank()) {
LOG.info("Using API key from environment variable {}", API_KEY_ENV_VAR);
return envApiKey;
}
String propsApiKey = props.getProperty("api.key");
return propsApiKey != null ? propsApiKey : "";
}
private String getRequiredProperty(Properties props, String key) {
String value = props.getProperty(key);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Required property missing: " + key);
}
return normalizePath(value.trim());
}
private String getOptionalProperty(Properties props, String key, String defaultValue) {
String value = props.getProperty(key);
return (value == null || value.isBlank()) ? defaultValue : normalizePath(value.trim());
}
/**
* Normalizes a property value that represents a path.
* Converts backslashes to forward slashes to avoid issues with Java Properties
* escape sequence interpretation (e.g., \t becoming tab).
* Also trims whitespace from the value.
*/
private String normalizePath(String value) {
return value.replace('\\', '/');
}
private int parseInt(String value) {
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
throw new IllegalStateException("Invalid integer value for property: " + value, e);
}
}
private URI parseUri(String value) {
try {
return new URI(value.trim());
} catch (URISyntaxException e) {
throw new IllegalStateException("Invalid URI value for property: " + value, e);
}
}
}

View File

@@ -0,0 +1,5 @@
/**
* Configuration adapters for outbound infrastructure access.
* Contains implementations of configuration loading from external sources.
*/
package de.gecheckt.pdf.umbenenner.adapter.outbound.configuration;

View File

@@ -0,0 +1,8 @@
/**
* Outbound adapters for infrastructure interactions.
* <p>
* This package contains implementations of outbound ports for external systems
* (PDF processing, database access, HTTP clients, etc.).
* AP-003: Currently empty as no outbound implementations have been created yet.
*/
package de.gecheckt.pdf.umbenenner.adapter.outbound;

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,295 @@
package de.gecheckt.pdf.umbenenner.adapter.outbound.configuration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.FileWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link PropertiesConfigurationPortAdapter}.
* <p>
* Tests cover valid configuration loading, missing mandatory properties,
* invalid property values, and API-key environment variable precedence.
*/
class PropertiesConfigurationPortAdapterTest {
private Function<String, String> emptyEnvLookup;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
emptyEnvLookup = key -> null;
}
@Test
void loadConfiguration_successWithValidProperties() throws Exception {
Path configFile = createConfigFile("valid-config.properties");
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertNotNull(config);
// Use endsWith to handle platform-specific path separators
assertTrue(config.sourceFolder().toString().endsWith("source"));
assertTrue(config.targetFolder().toString().endsWith("target"));
assertTrue(config.sqliteFile().toString().endsWith("db.sqlite"));
assertEquals("https://api.example.com", config.apiBaseUrl().toString());
assertEquals("gpt-4", config.apiModel());
assertEquals(30, config.apiTimeoutSeconds());
assertEquals(3, config.maxRetriesTransient());
assertEquals(100, config.maxPages());
assertEquals(50000, config.maxTextCharacters());
assertTrue(config.promptTemplateFile().toString().endsWith("prompt.txt"));
assertTrue(config.runtimeLockFile().toString().endsWith("lock.lock"));
assertTrue(config.logDirectory().toString().endsWith("logs"));
assertEquals("DEBUG", config.logLevel());
assertEquals("test-api-key-from-properties", config.apiKey());
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsAbsent() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey(), "API key should be empty when not in properties and no env var");
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsNull() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function<String, String> envLookup = key -> null;
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey());
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsEmpty() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function<String, String> envLookup = key -> "";
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey(), "Empty env var should fall back to empty string");
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsBlank() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function<String, String> envLookup = key -> " ";
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey(), "Blank env var should fall back to empty string");
}
@Test
void loadConfiguration_envVarOverridesPropertiesApiKey() throws Exception {
Path configFile = createConfigFile("valid-config.properties");
Function<String, String> envLookup = key -> {
if ("PDF_UMBENENNER_API_KEY".equals(key)) {
return "env-api-key-override";
}
return null;
};
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("env-api-key-override", config.apiKey(), "Environment variable should override properties");
}
@Test
void loadConfiguration_throwsIllegalStateExceptionWhenRequiredPropertyMissing() throws Exception {
Path configFile = createConfigFile("missing-required.properties");
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> adapter.loadConfiguration()
);
assertTrue(exception.getMessage().contains("Required property missing"));
assertTrue(exception.getMessage().contains("sqlite.file"));
}
@Test
void loadConfiguration_throwsRuntimeExceptionWhenConfigFileNotFound() {
Path nonExistentFile = tempDir.resolve("nonexistent.properties");
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, nonExistentFile);
RuntimeException exception = assertThrows(
RuntimeException.class,
() -> adapter.loadConfiguration()
);
assertTrue(exception.getMessage().contains("Failed to load configuration"));
assertTrue(exception.getCause() instanceof java.io.FileNotFoundException);
}
@Test
void loadConfiguration_parsesIntegerValuesCorrectly() throws Exception {
Path configFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=60\n" +
"max.retries.transient=5\n" +
"max.pages=200\n" +
"max.text.characters=100000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals(60, config.apiTimeoutSeconds());
assertEquals(5, config.maxRetriesTransient());
assertEquals(200, config.maxPages());
assertEquals(100000, config.maxTextCharacters());
}
@Test
void loadConfiguration_handlesWhitespaceInIntegerValues() throws Exception {
Path configFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds= 45 \n" +
"max.retries.transient=2\n" +
"max.pages=150\n" +
"max.text.characters=75000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals(45, config.apiTimeoutSeconds(), "Whitespace should be trimmed from integer values");
}
@Test
void loadConfiguration_throwsIllegalStateExceptionForInvalidIntegerValue() throws Exception {
Path configFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=not-a-number\n" +
"max.retries.transient=2\n" +
"max.pages=150\n" +
"max.text.characters=75000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> adapter.loadConfiguration()
);
assertTrue(exception.getMessage().contains("Invalid integer value"));
}
@Test
void loadConfiguration_parsesUriCorrectly() throws Exception {
Path configFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com:8080/v1\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("https://api.example.com:8080/v1", config.apiBaseUrl().toString());
}
@Test
void loadConfiguration_defaultsOptionalValuesWhenNotPresent() throws Exception {
Path configFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.runtimeLockFile().toString(), "runtime.lock.file should default to empty");
assertEquals("", config.logDirectory().toString(), "log.directory should default to empty");
assertEquals("INFO", config.logLevel(), "log.level should default to INFO");
}
private Path createConfigFile(String resourceName) throws Exception {
Path sourceResource = Path.of("src/test/resources", resourceName);
Path targetConfigFile = tempDir.resolve("application.properties");
// Copy content from resource file
Files.copy(sourceResource, targetConfigFile);
return targetConfigFile;
}
private Path createInlineConfig(String content) throws Exception {
Path configFile = tempDir.resolve("config.properties");
try (FileWriter writer = new FileWriter(configFile.toFile())) {
writer.write(content);
}
return configFile;
}
}

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,11 @@
source.folder=/tmp/source
target.folder=/tmp/target
# sqlite.file is missing
api.baseUrl=https://api.example.com
api.model=gpt-4
api.timeoutSeconds=30
max.retries.transient=3
max.pages=100
max.text.characters=50000
prompt.template.file=/tmp/prompt.txt
api.key=test-api-key

View File

@@ -0,0 +1,10 @@
source.folder=/tmp/source
target.folder=/tmp/target
sqlite.file=/tmp/db.sqlite
api.baseUrl=https://api.example.com
api.model=gpt-4
api.timeoutSeconds=30
max.retries.transient=3
max.pages=100
max.text.characters=50000
prompt.template.file=/tmp/prompt.txt

View File

@@ -0,0 +1,14 @@
source.folder=/tmp/source
target.folder=/tmp/target
sqlite.file=/tmp/db.sqlite
api.baseUrl=https://api.example.com
api.model=gpt-4
api.timeoutSeconds=30
max.retries.transient=3
max.pages=100
max.text.characters=50000
prompt.template.file=/tmp/prompt.txt
runtime.lock.file=/tmp/lock.lock
log.directory=/tmp/logs
log.level=DEBUG
api.key=test-api-key-from-properties

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>pdf-umbenenner-application</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Internal dependency -->
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,21 @@
package de.gecheckt.pdf.umbenenner.application.config;
/**
* Exception thrown when startup configuration validation fails.
* <p>
* Contains an aggregated message describing all validation errors found.
* This is a controlled failure mode that prevents processing from starting.
*/
public class InvalidStartConfigurationException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the exception with an aggregated error message.
*
* @param message the aggregated validation error message
*/
public InvalidStartConfigurationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.config;
import java.net.URI;
import java.nio.file.Path;
/**
* Typed immutable configuration model for PDF Umbenenner startup parameters.
* AP-005: Represents all M1-relevant configuration properties with strong typing.
*/
public record StartConfiguration(
Path sourceFolder,
Path targetFolder,
Path sqliteFile,
URI apiBaseUrl,
String apiModel,
int apiTimeoutSeconds,
int maxRetriesTransient,
int maxPages,
int maxTextCharacters,
Path promptTemplateFile,
Path runtimeLockFile,
Path logDirectory,
String logLevel,
String apiKey
)
{ }

View File

@@ -0,0 +1,198 @@
package de.gecheckt.pdf.umbenenner.application.config;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Validates {@link StartConfiguration} before processing can begin.
* <p>
* Performs mandatory field checks, numeric range validation, URI scheme validation,
* and basic path existence checks. Throws {@link InvalidStartConfigurationException}
* if any validation rule fails.
*/
public class StartConfigurationValidator {
private static final Logger LOG = LogManager.getLogger(StartConfigurationValidator.class);
/**
* Validates the given configuration.
* <p>
* Checks all mandatory fields, numeric constraints, URI validity, and path existence.
* If validation fails, throws {@link InvalidStartConfigurationException} with an
* aggregated error message listing all problems.
*
* @param config the configuration to validate, must not be null
* @throws InvalidStartConfigurationException if any validation rule fails
*/
public void validate(StartConfiguration config) {
List<String> errors = new ArrayList<>();
// Mandatory string/path presence checks
validateSourceFolder(config.sourceFolder(), errors);
validateTargetFolder(config.targetFolder(), errors);
validateSqliteFile(config.sqliteFile(), errors);
validateApiBaseUrl(config.apiBaseUrl(), errors);
validateApiModel(config.apiModel(), errors);
validatePromptTemplateFile(config.promptTemplateFile(), errors);
// Numeric validation
validateApiTimeoutSeconds(config.apiTimeoutSeconds(), errors);
validateMaxRetriesTransient(config.maxRetriesTransient(), errors);
validateMaxPages(config.maxPages(), errors);
validateMaxTextCharacters(config.maxTextCharacters(), errors);
// Path relationship validation
validateSourceAndTargetNotSame(config.sourceFolder(), config.targetFolder(), errors);
// Optional path validations (only if present)
validateRuntimeLockFile(config.runtimeLockFile(), errors);
validateLogDirectory(config.logDirectory(), errors);
if (!errors.isEmpty()) {
String errorMessage = "Invalid startup configuration:\n" + String.join("\n", errors);
throw new InvalidStartConfigurationException(errorMessage);
}
LOG.info("Configuration validation successful.");
}
private void validateSourceFolder(Path sourceFolder, List<String> errors) {
if (sourceFolder == null) {
errors.add("- source.folder: must not be null");
return;
}
if (!Files.exists(sourceFolder)) {
errors.add("- source.folder: path does not exist: " + sourceFolder);
} else if (!Files.isDirectory(sourceFolder)) {
errors.add("- source.folder: path is not a directory: " + sourceFolder);
}
}
private void validateTargetFolder(Path targetFolder, List<String> errors) {
if (targetFolder == null) {
errors.add("- target.folder: must not be null");
return;
}
if (!Files.exists(targetFolder)) {
errors.add("- target.folder: path does not exist: " + targetFolder);
} else if (!Files.isDirectory(targetFolder)) {
errors.add("- target.folder: path is not a directory: " + targetFolder);
}
}
private void validateSqliteFile(Path sqliteFile, List<String> errors) {
if (sqliteFile == null) {
errors.add("- sqlite.file: must not be null");
return;
}
Path parent = sqliteFile.getParent();
if (parent == null) {
errors.add("- sqlite.file: has no parent directory: " + sqliteFile);
} else if (!Files.exists(parent)) {
errors.add("- sqlite.file: parent directory does not exist: " + parent);
} else if (!Files.isDirectory(parent)) {
errors.add("- sqlite.file: parent is not a directory: " + parent);
}
}
private void validateApiBaseUrl(java.net.URI apiBaseUrl, List<String> errors) {
if (apiBaseUrl == null) {
errors.add("- api.baseUrl: must not be null");
return;
}
if (!apiBaseUrl.isAbsolute()) {
errors.add("- api.baseUrl: must be an absolute URI: " + apiBaseUrl);
return;
}
String scheme = apiBaseUrl.getScheme();
if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
errors.add("- api.baseUrl: scheme must be http or https, got: " + scheme);
}
}
private void validateApiModel(String apiModel, List<String> errors) {
if (apiModel == null || apiModel.isBlank()) {
errors.add("- api.model: must not be null or blank");
}
}
private void validateApiTimeoutSeconds(int apiTimeoutSeconds, List<String> errors) {
if (apiTimeoutSeconds <= 0) {
errors.add("- api.timeoutSeconds: must be > 0, got: " + apiTimeoutSeconds);
}
}
private void validateMaxRetriesTransient(int maxRetriesTransient, List<String> errors) {
if (maxRetriesTransient < 0) {
errors.add("- max.retries.transient: must be >= 0, got: " + maxRetriesTransient);
}
}
private void validateMaxPages(int maxPages, List<String> errors) {
if (maxPages <= 0) {
errors.add("- max.pages: must be > 0, got: " + maxPages);
}
}
private void validateMaxTextCharacters(int maxTextCharacters, List<String> errors) {
if (maxTextCharacters <= 0) {
errors.add("- max.text.characters: must be > 0, got: " + maxTextCharacters);
}
}
private void validatePromptTemplateFile(Path promptTemplateFile, List<String> errors) {
if (promptTemplateFile == null) {
errors.add("- prompt.template.file: must not be null");
return;
}
if (!Files.exists(promptTemplateFile)) {
errors.add("- prompt.template.file: path does not exist: " + promptTemplateFile);
} else if (!Files.isRegularFile(promptTemplateFile)) {
errors.add("- prompt.template.file: path is not a regular file: " + promptTemplateFile);
}
}
private void validateSourceAndTargetNotSame(Path sourceFolder, Path targetFolder, List<String> errors) {
if (sourceFolder != null && targetFolder != null) {
try {
Path normalizedSource = sourceFolder.toRealPath();
Path normalizedTarget = targetFolder.toRealPath();
if (normalizedSource.equals(normalizedTarget)) {
errors.add("- source.folder and target.folder must not resolve to the same path: " + normalizedSource);
}
} catch (Exception e) {
// If toRealPath fails (e.g., path doesn't exist), skip this check
// The individual existence checks will catch missing paths
}
}
}
private void validateRuntimeLockFile(Path runtimeLockFile, List<String> errors) {
if (runtimeLockFile != null && !runtimeLockFile.toString().isBlank()) {
Path parent = runtimeLockFile.getParent();
if (parent != null) {
if (!Files.exists(parent)) {
errors.add("- runtime.lock.file: parent directory does not exist: " + parent);
} else if (!Files.isDirectory(parent)) {
errors.add("- runtime.lock.file: parent is not a directory: " + parent);
}
}
}
}
private void validateLogDirectory(Path logDirectory, List<String> errors) {
if (logDirectory != null && !logDirectory.toString().isBlank()) {
if (Files.exists(logDirectory)) {
if (!Files.isDirectory(logDirectory)) {
errors.add("- log.directory: exists but is not a directory: " + logDirectory);
}
}
// If it doesn't exist yet, that's acceptable - we don't auto-create
}
}
}

View File

@@ -0,0 +1,5 @@
/**
* Configuration model types for the PDF Umbenenner application.
* Contains typed configuration objects representing startup parameters.
*/
package de.gecheckt.pdf.umbenenner.application.config;

View File

@@ -0,0 +1,5 @@
/**
* Application layer containing use cases and inbound ports.
* This package defines the business logic boundary without infrastructure details.
*/
package de.gecheckt.pdf.umbenenner.application;

View File

@@ -0,0 +1,18 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* 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.
*/
public interface RunBatchProcessingUseCase {
/**
* Executes the batch processing workflow.
* <p>
* AP-003: This method performs no actual work, only validates the call chain.
*
* @return true if the workflow completed successfully, false otherwise
*/
boolean execute();
}

View File

@@ -0,0 +1,5 @@
/**
* Inbound ports (application service interfaces) for the hexagonal architecture.
* Use cases in this package are invoked by adapters from the outside world.
*/
package de.gecheckt.pdf.umbenenner.application.port.in;

View File

@@ -0,0 +1,17 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
/**
* Outbound port for configuration access.
* AP-005: Minimal interface for loading typed startup configuration.
*/
public interface ConfigurationPort {
/**
* Loads and returns the startup configuration.
*
* @return the loaded StartConfiguration
*/
StartConfiguration loadConfiguration();
}

View File

@@ -0,0 +1,5 @@
/**
* Outbound ports for the application layer.
* Defines interfaces for infrastructure access from the application layer.
*/
package de.gecheckt.pdf.umbenenner.application.port.out;

View File

@@ -0,0 +1,41 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Minimal no-op implementation of {@link RunBatchProcessingUseCase}.
* <p>
* AP-003 Implementation: Provides a controlled, non-functional startup path
* without any business logic, PDF processing, or infrastructure access.
* <p>
* AP-005: Accepts {@link ConfigurationPort} to load typed startup configuration.
*/
public class NoOpRunBatchProcessingUseCase implements RunBatchProcessingUseCase {
private static final Logger LOG = LogManager.getLogger(NoOpRunBatchProcessingUseCase.class);
private final ConfigurationPort configurationPort;
/**
* Creates the no-op use case with a configuration port.
*
* @param configurationPort the configuration port for loading startup configuration
*/
public NoOpRunBatchProcessingUseCase(ConfigurationPort configurationPort) {
this.configurationPort = configurationPort;
}
@Override
public boolean execute() {
// 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());
// AP-003: Intentional no-op - validates the technical call chain only
return true;
}
}

View File

@@ -0,0 +1,5 @@
/**
* Use case implementations that realize the inbound port contracts.
* Currently contains minimal no-op implementations for AP-003 technical validation.
*/
package de.gecheckt.pdf.umbenenner.application.usecase;

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,686 @@
package de.gecheckt.pdf.umbenenner.application.config;
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 static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link StartConfigurationValidator}.
* <p>
* Tests cover valid configuration, missing mandatory fields, invalid values,
* and path relationship validations.
*/
class StartConfigurationValidatorTest {
private final StartConfigurationValidator validator = new StartConfigurationValidator();
@TempDir
Path tempDir;
@Test
void validate_successWithValidConfiguration() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
"test-api-key"
);
assertDoesNotThrow(() -> validator.validate(config));
}
@Test
void validate_failsWhenSourceFolderIsNull() {
StartConfiguration config = new StartConfiguration(
null,
tempDir.resolve("target"),
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
tempDir.resolve("prompt.txt"),
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("source.folder: must not be null"));
}
@Test
void validate_failsWhenTargetFolderIsNull() {
StartConfiguration config = new StartConfiguration(
tempDir.resolve("source"),
null,
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
tempDir.resolve("prompt.txt"),
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("target.folder: must not be null"));
}
@Test
void validate_failsWhenSqliteFileIsNull() {
StartConfiguration config = new StartConfiguration(
tempDir.resolve("source"),
tempDir.resolve("target"),
null,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
tempDir.resolve("prompt.txt"),
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("sqlite.file: must not be null"));
}
@Test
void validate_failsWhenApiBaseUrlIsNull() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
null,
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.baseUrl: must not be null"));
}
@Test
void validate_failsWhenApiModelIsNull() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
null,
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
}
@Test
void validate_failsWhenPromptTemplateFileIsNull() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
null,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("prompt.template.file: must not be null"));
}
@Test
void validate_failsWhenApiTimeoutSecondsIsZeroOrNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
0,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.timeoutSeconds: must be > 0"));
}
@Test
void validate_failsWhenMaxRetriesTransientIsNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
-1,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("max.retries.transient: must be >= 0"));
}
@Test
void validate_failsWhenMaxPagesIsZeroOrNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
0,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("max.pages: must be > 0"));
}
@Test
void validate_failsWhenMaxTextCharactersIsZeroOrNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
-1,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("max.text.characters: must be > 0"));
}
@Test
void validate_failsWhenSourceFolderDoesNotExist() throws Exception {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
tempDir.resolve("nonexistent"),
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("source.folder: path does not exist"));
}
@Test
void validate_failsWhenSourceFolderIsNotADirectory() throws Exception {
Path sourceFile = Files.createFile(tempDir.resolve("sourcefile.txt"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFile,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("source.folder: path is not a directory"));
}
@Test
void validate_failsWhenTargetFolderDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
tempDir.resolve("nonexistent"),
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("target.folder: path does not exist"));
}
@Test
void validate_failsWhenTargetFolderIsNotADirectory() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFile = Files.createFile(tempDir.resolve("targetfile.txt"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFile,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("target.folder: path is not a directory"));
}
@Test
void validate_failsWhenSqliteFileParentDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
tempDir.resolve("nonexistent/db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("sqlite.file: parent directory does not exist"));
}
@Test
void validate_failsWhenApiBaseUrlIsNotAbsolute() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("/api/v1"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI"));
}
@Test
void validate_failsWhenApiBaseUrlHasUnsupportedScheme() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("ftp://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.baseUrl: scheme must be http or https"));
}
@Test
void validate_failsWhenPromptTemplateFileDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
tempDir.resolve("nonexistent.txt"),
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("prompt.template.file: path does not exist"));
}
@Test
void validate_failsWhenPromptTemplateFileIsNotARegularFile() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path dirForPrompt = Files.createDirectory(tempDir.resolve("promptdir"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
dirForPrompt,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("prompt.template.file: path is not a regular file"));
}
@Test
void validate_failsWhenSourceAndTargetAreSamePath() throws Exception {
Path sameFolder = Files.createDirectory(tempDir.resolve("samefolder"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sameFolder,
sameFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("source.folder and target.folder must not resolve to the same path"));
}
@Test
void validate_failsWhenMultipleErrorsOccur() {
StartConfiguration config = new StartConfiguration(
null,
null,
null,
null,
null,
0,
-1,
0,
-1,
null,
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
String message = exception.getMessage();
assertTrue(message.contains("source.folder: must not be null"));
assertTrue(message.contains("target.folder: must not be null"));
assertTrue(message.contains("sqlite.file: must not be null"));
assertTrue(message.contains("api.baseUrl: must not be null"));
assertTrue(message.contains("api.model: must not be null or blank"));
assertTrue(message.contains("prompt.template.file: must not be null"));
assertTrue(message.contains("api.timeoutSeconds: must be > 0"));
assertTrue(message.contains("max.retries.transient: must be >= 0"));
assertTrue(message.contains("max.pages: must be > 0"));
assertTrue(message.contains("max.text.characters: must be > 0"));
}
}

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>pdf-umbenenner-bootstrap</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Internal dependencies -->
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-adapter-out</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication</mainClass>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,143 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
import org.apache.logging.log4j.LogManager;
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.application.config.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator;
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;
/**
* Manual bootstrap runner that constructs the object graph and drives the startup flow.
* <p>
* 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.
* <p>
* AP-006: Validates configuration before processing begins, returns exit code 2 on invalid config.
*/
public class BootstrapRunner {
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
private final ConfigurationPortFactory configPortFactory;
private final ValidatorFactory validatorFactory;
private final UseCaseFactory useCaseFactory;
private final CommandFactory commandFactory;
/**
* Functional interface for creating a ConfigurationPort.
*/
@FunctionalInterface
public interface ConfigurationPortFactory {
ConfigurationPort create();
}
/**
* Functional interface for creating a StartConfigurationValidator.
*/
@FunctionalInterface
public interface ValidatorFactory {
StartConfigurationValidator create();
}
/**
* Functional interface for creating a RunBatchProcessingUseCase.
*/
@FunctionalInterface
public interface UseCaseFactory {
RunBatchProcessingUseCase create(ConfigurationPort configPort);
}
/**
* Functional interface for creating a SchedulerBatchCommand.
*/
@FunctionalInterface
public interface CommandFactory {
SchedulerBatchCommand create(RunBatchProcessingUseCase useCase);
}
/**
* Creates the BootstrapRunner with default factories for production use.
*/
public BootstrapRunner() {
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
this.validatorFactory = StartConfigurationValidator::new;
this.useCaseFactory = NoOpRunBatchProcessingUseCase::new;
this.commandFactory = SchedulerBatchCommand::new;
}
/**
* Creates the BootstrapRunner with custom factories for testing.
*
* @param configPortFactory factory for creating ConfigurationPort 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,
ValidatorFactory validatorFactory,
UseCaseFactory useCaseFactory,
CommandFactory commandFactory) {
this.configPortFactory = configPortFactory;
this.validatorFactory = validatorFactory;
this.useCaseFactory = useCaseFactory;
this.commandFactory = commandFactory;
}
/**
* Runs the application startup sequence.
* <p>
* AP-003: Manually wires the object graph and invokes the CLI command.
* AP-005: Wires ConfigurationPort adapter and passes it to the use case.
* AP-006: Validates configuration before allowing processing to start.
*
* @return exit code: 0 for success, 1 for unexpected failure, 2 for invalid configuration
*/
public int run() {
LOG.info("Bootstrap flow started.");
try {
// Step 1: Create the configuration port adapter (adapter-out layer)
ConfigurationPort configPort = configPortFactory.create();
// Step 2: Load configuration
var config = configPort.loadConfiguration();
// Step 3: Validate configuration (AP-006)
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 5: Create the CLI command adapter with the use case
SchedulerBatchCommand command = commandFactory.create(useCase);
// Step 6: Execute the command
boolean success = command.run();
if (success) {
LOG.info("No-op startup path completed successfully.");
}
return success ? 0 : 1;
} catch (InvalidStartConfigurationException e) {
// Controlled failure for invalid configuration - log clearly without stack trace
LOG.error("Configuration validation failed: {}", e.getMessage());
return 2;
} catch (IllegalStateException e) {
// Configuration loading failed due to missing/invalid required properties
// Treat as invalid configuration for controlled failure
LOG.error("Configuration loading failed: {}", e.getMessage());
return 2;
} catch (Exception e) {
LOG.error("Bootstrap failure during startup.", e);
return 1;
}
}
}

View File

@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Main entry point for the PDF Umbenenner application.
* <p>
* AP-003: Delegates to {@link BootstrapRunner} for manual object graph construction
* and execution of the minimal no-op startup path.
*/
public class PdfUmbenennerApplication {
private static final Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class);
/**
* Application entry point.
*
* @param args command line arguments (currently unused)
*/
public static void main(String[] args) {
LOG.info("Starting PDF Umbenenner application...");
try {
BootstrapRunner runner = new BootstrapRunner();
int exitCode = runner.run();
if (exitCode == 0) {
LOG.info("PDF Umbenenner application completed successfully.");
} else {
LOG.error("PDF Umbenenner application terminated with error code {}.", exitCode);
}
System.exit(exitCode);
} catch (Exception e) {
LOG.fatal("Unexpected technical bootstrap failure in PDF Umbenenner application.", e);
System.exit(1);
}
}
}

View File

@@ -0,0 +1,7 @@
/**
* Bootstrap module for application startup and technical wiring.
* <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.
*/
package de.gecheckt.pdf.umbenenner.bootstrap;

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<!-- Console appender for stdout -->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
<!-- Rolling file appender for logs in ./logs/ directory -->
<RollingFile name="File" fileName="logs/pdf-umbenenner.log"
filePattern="logs/pdf-umbenenner-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
<SizeBasedTriggeringPolicy size="10 MB"/>
</Policies>
<DefaultRolloverStrategy max="7"/>
</RollingFile>
</Appenders>
<Loggers>
<!-- Root logger at INFO level -->
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="File"/>
</Root>
</Loggers>
</Configuration>

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,201 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
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.RunBatchProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
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 static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link BootstrapRunner}.
* <p>
* Tests cover the bootstrap orchestration behavior including success path,
* invalid configuration handling, and unexpected failure handling.
*/
class BootstrapRunnerTest {
@TempDir
Path tempDir;
@Test
void run_returnsZeroOnSuccess() throws Exception {
// Create a mock configuration port that returns valid config
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
// Create mock factories that return working components
BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort,
StartConfigurationValidator::new,
port -> new MockRunBatchProcessingUseCase(true),
useCase -> new SchedulerBatchCommand(useCase)
);
int exitCode = runner.run();
assertEquals(0, exitCode, "Success path should return exit code 0");
}
@Test
void run_returnsTwoOnInvalidConfiguration() throws Exception {
// Create a mock configuration port that returns valid config
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
// Create a custom validator that always throws InvalidStartConfigurationException
StartConfigurationValidator failingValidator = new StartConfigurationValidator() {
@Override
public void validate(StartConfiguration config) {
throw new InvalidStartConfigurationException("Simulated validation failure");
}
};
BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort,
() -> failingValidator,
port -> new MockRunBatchProcessingUseCase(true),
useCase -> new SchedulerBatchCommand(useCase)
);
int exitCode = runner.run();
assertEquals(2, exitCode, "Invalid configuration should return exit code 2");
}
@Test
void run_returnsTwoOnConfigurationLoadingFailure() {
// Create a mock configuration port that throws IllegalStateException
ConfigurationPort failingConfigPort = () -> {
throw new IllegalStateException("Simulated configuration loading failure");
};
BootstrapRunner runner = new BootstrapRunner(
() -> failingConfigPort,
StartConfigurationValidator::new,
port -> new MockRunBatchProcessingUseCase(true),
useCase -> new SchedulerBatchCommand(useCase)
);
int exitCode = runner.run();
assertEquals(2, exitCode, "Configuration loading failure should return exit code 2");
}
@Test
void run_returnsOneOnUnexpectedException() {
// Create a mock configuration port that throws a generic exception
ConfigurationPort throwingConfigPort = () -> {
throw new RuntimeException("Simulated unexpected failure");
};
BootstrapRunner runner = new BootstrapRunner(
() -> throwingConfigPort,
StartConfigurationValidator::new,
port -> new MockRunBatchProcessingUseCase(true),
useCase -> new SchedulerBatchCommand(useCase)
);
int exitCode = runner.run();
assertEquals(1, exitCode, "Unexpected exception should return exit code 1");
}
@Test
void run_returnsOneWhenCommandReturnsFalse() 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;
BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort,
StartConfigurationValidator::new,
port -> failingUseCase,
useCase -> new SchedulerBatchCommand(useCase)
);
int exitCode = runner.run();
assertEquals(1, exitCode, "Command returning false should return exit code 1");
}
@Test
void run_withDefaultConstructor_usesRealImplementations() {
// This test verifies that the default constructor creates a functional runner
// We can't fully test it without actual config files, but we can verify instantiation
BootstrapRunner runner = new BootstrapRunner();
assertNotNull(runner, "Default constructor should create a valid BootstrapRunner");
}
/**
* Mock ConfigurationPort that returns a valid StartConfiguration.
*/
private static class MockConfigurationPort implements ConfigurationPort {
private final Path tempDir;
private final boolean shouldSucceed;
MockConfigurationPort(Path tempDir, boolean shouldSucceed) {
this.tempDir = tempDir;
this.shouldSucceed = shouldSucceed;
}
@Override
public StartConfiguration loadConfiguration() {
if (!shouldSucceed) {
throw new IllegalStateException("Mock configuration loading failed");
}
try {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
return new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
"test-api-key"
);
} catch (Exception e) {
throw new RuntimeException("Failed to create mock configuration", e);
}
}
}
/**
* Mock RunBatchProcessingUseCase that returns a configurable result.
*/
private static class MockRunBatchProcessingUseCase implements RunBatchProcessingUseCase {
private final boolean shouldSucceed;
MockRunBatchProcessingUseCase(boolean shouldSucceed) {
this.shouldSucceed = shouldSucceed;
}
@Override
public boolean execute() {
return shouldSucceed;
}
}
}

View File

@@ -0,0 +1,275 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* AP-008: Executable JAR smoke tests for M1 target state verification.
* <p>
* These tests verify that the shaded executable JAR can be run via {@code java -jar}
* and behaves correctly for both success and invalid configuration scenarios.
* <p>
* Tests are executed by the maven-failsafe-plugin after the package phase.
* The *IT suffix ensures failsafe picks them up as integration tests.
*/
class ExecutableJarSmokeTestIT {
private static final String JAVA_EXECUTABLE = "java";
private static final long PROCESS_TIMEOUT_MS = 30000;
/**
* Success-path smoke test: verifies the JAR starts successfully with valid configuration.
* <p>
* Verifies:
* - The shaded JAR file exists
* - java -jar executes successfully
* - Exit code is 0
* - Configuration is loaded successfully
* - Logging produces output
* - Process ends in a controlled way
* - No functional PDF processing occurs
*/
@Test
void jar_startsSuccessfullyWithValidConfiguration(@TempDir Path workDir) throws Exception {
// Create runtime fixtures in the temporary working directory
Path configDir = Files.createDirectory(workDir.resolve("config"));
Path sourceDir = Files.createDirectory(workDir.resolve("source"));
Path targetDir = Files.createDirectory(workDir.resolve("target"));
Path logsDir = Files.createDirectory(workDir.resolve("logs"));
Path dbParent = Files.createDirectory(workDir.resolve("data"));
Path promptDir = Files.createDirectory(workDir.resolve("config/prompts"));
Path sqliteFile = Files.createFile(dbParent.resolve("pdf-umbenenner.db"));
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
Files.writeString(promptTemplateFile, "Test prompt template for smoke test.");
// Write valid application.properties
Path configFile = configDir.resolve("application.properties");
String validConfig = """
source.folder=%s
target.folder=%s
sqlite.file=%s
api.baseUrl=http://localhost:8080/api
api.model=gpt-4o-mini
api.timeoutSeconds=30
max.retries.transient=3
max.pages=10
max.text.characters=5000
prompt.template.file=%s
runtime.lock.file=%s/lock.pid
log.directory=%s
log.level=INFO
api.key=test-api-key-for-smoke-test
""".formatted(
sourceDir.toAbsolutePath(),
targetDir.toAbsolutePath(),
sqliteFile.toAbsolutePath(),
promptTemplateFile.toAbsolutePath(),
workDir.toAbsolutePath(),
logsDir.toAbsolutePath()
);
Files.writeString(configFile, validConfig);
// Find the shaded JAR - look in target directory relative to project root
Path projectRoot = Paths.get(System.getProperty("user.dir"));
Path bootstrapTarget = projectRoot.resolve("pdf-umbenenner-bootstrap/target");
if (!Files.exists(bootstrapTarget)) {
// Fallback: try relative from current execution context
bootstrapTarget = Paths.get("target");
}
assertTrue(Files.exists(bootstrapTarget), "Bootstrap target directory must exist: " + bootstrapTarget);
File[] jars = bootstrapTarget.toFile().listFiles((dir, name) ->
name.endsWith(".jar") && !name.contains("original") && !name.contains("tests")
);
assertNotNull(jars, "JAR files should exist in target directory");
assertTrue(jars.length > 0, "At least one JAR should exist in target directory");
Path shadedJar = Paths.get(jars[0].getAbsolutePath());
// Prefer the shaded JAR if multiple exist
for (File jar : jars) {
if (jar.getName().contains("shaded") || jar.getName().equals("pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar")) {
shadedJar = jar.toPath().toAbsolutePath();
break;
}
}
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar);
// Build the java -jar command
List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE);
command.add("-jar");
command.add(shadedJar.toString());
// Run the process in the work directory where config/application.properties will be found
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(workDir.toFile());
pb.redirectErrorStream(true);
System.out.println("[SMOKE-TEST] JAR path: " + shadedJar.toAbsolutePath());
System.out.println("[SMOKE-TEST] Working directory: " + workDir.toAbsolutePath());
System.out.println("[SMOKE-TEST] Command: " + String.join(" ", command));
Process process = pb.start();
// Wait for process completion with timeout
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
assertTrue(completed, "Process should complete within " + PROCESS_TIMEOUT_MS + "ms timeout");
int exitCode = process.exitValue();
// Capture all output for diagnostic purposes
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
System.out.println("[SMOKE-TEST] Exit code: " + exitCode);
System.out.println("[SMOKE-TEST] Subprocess stdout/stderr:\n" + outputText);
assertEquals(0, exitCode, "Successful startup should return exit code 0. Output was: " + outputText);
// Verify logging output was produced (check console output)
assertTrue(
outputText.contains("Starting") ||
outputText.contains("Bootstrap") ||
outputText.contains("completed") ||
outputText.contains("successfully"),
"Output should contain startup/shutdown indicators. Got: " + outputText
);
// Verify no unexpected artifacts were created beyond our fixtures
// Count top-level entries - should only be our created directories
long fileCount = Files.list(workDir).count();
assertTrue(fileCount <= 7, "No extra files should be created beyond fixtures. Found: " + fileCount + " entries");
}
/**
* Invalid-configuration smoke test: verifies controlled failure with exit code 2.
* <p>
* Verifies:
* - java -jar runs against invalid configuration
* - Exit code is 2
* - Startup fails before any processing
* - Failure is controlled (not a crash/hang)
* - Error output indicates configuration validation failure
*/
@Test
void jar_failsControlledWithInvalidConfiguration(@TempDir Path workDir) throws Exception {
// Create runtime fixtures with INVALID configuration
Path configDir = Files.createDirectory(workDir.resolve("config"));
Path sourceDir = Files.createDirectory(workDir.resolve("source"));
// Intentionally missing target folder - this should cause validation failure
Path dbParent = Files.createDirectory(workDir.resolve("data"));
Path promptDir = Files.createDirectory(workDir.resolve("config/prompts"));
Path sqliteFile = Files.createFile(dbParent.resolve("pdf-umbenenner.db"));
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
Files.writeString(promptTemplateFile, "Test prompt template.");
// Write INVALID application.properties (missing required target.folder)
Path configFile = configDir.resolve("application.properties");
String invalidConfig = """
source.folder=%s
# target.folder is intentionally missing - should cause validation failure
sqlite.file=%s
api.baseUrl=http://localhost:8080/api
api.model=gpt-4o-mini
api.timeoutSeconds=30
max.retries.transient=3
max.pages=10
max.text.characters=5000
prompt.template.file=%s
log.directory=%s/logs
log.level=INFO
api.key=test-api-key
""".formatted(
sourceDir.toAbsolutePath(),
sqliteFile.toAbsolutePath(),
promptTemplateFile.toAbsolutePath(),
workDir.toAbsolutePath()
);
Files.writeString(configFile, invalidConfig);
// Find the shaded JAR
Path projectRoot = Paths.get(System.getProperty("user.dir"));
Path bootstrapTarget = projectRoot.resolve("pdf-umbenenner-bootstrap/target");
if (!Files.exists(bootstrapTarget)) {
bootstrapTarget = Paths.get("target");
}
assertTrue(Files.exists(bootstrapTarget), "Bootstrap target directory must exist: " + bootstrapTarget);
File[] jars = bootstrapTarget.toFile().listFiles((dir, name) ->
name.endsWith(".jar") && !name.contains("original") && !name.contains("tests")
);
assertNotNull(jars, "JAR files should exist in target directory");
assertTrue(jars.length > 0, "At least one JAR should exist");
Path shadedJar = Paths.get(jars[0].getAbsolutePath());
for (File jar : jars) {
if (jar.getName().contains("shaded") || jar.getName().equals("pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar")) {
shadedJar = jar.toPath().toAbsolutePath();
break;
}
}
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar);
// Build the java -jar command
List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE);
command.add("-jar");
command.add(shadedJar.toString());
// Run the process
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(workDir.toFile());
pb.redirectErrorStream(true);
System.out.println("[SMOKE-TEST-INVALID] JAR path: " + shadedJar.toAbsolutePath());
System.out.println("[SMOKE-TEST-INVALID] Working directory: " + workDir.toAbsolutePath());
System.out.println("[SMOKE-TEST-INVALID] Command: " + String.join(" ", command));
Process process = pb.start();
// Wait for process completion with timeout
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
assertTrue(completed, "Process should complete within timeout even on failure");
int exitCode = process.exitValue();
// Capture all output for diagnostic purposes
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
System.out.println("[SMOKE-TEST-INVALID] Exit code: " + exitCode);
System.out.println("[SMOKE-TEST-INVALID] Subprocess stdout/stderr:\n" + outputText);
assertEquals(2, exitCode, "Invalid configuration should return exit code 2. Output was: " + outputText);
// Verify error output indicates configuration failure
assertTrue(
outputText.toLowerCase().contains("config") ||
outputText.toLowerCase().contains("validation") ||
outputText.toLowerCase().contains("invalid") ||
outputText.toLowerCase().contains("error") ||
outputText.toLowerCase().contains("failed"),
"Output should indicate configuration/validation error. Got: " + outputText
);
}
}

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>pdf-umbenenner-domain</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1,7 @@
/**
* 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.
*/
package de.gecheckt.pdf.umbenenner.domain;

View File

@@ -0,0 +1 @@
# Keep directory

View File

@@ -0,0 +1 @@
# Keep directory

145
pom.xml Normal file
View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>pdf-umbenenner-domain</module>
<module>pdf-umbenenner-application</module>
<module>pdf-umbenenner-adapter-in-cli</module>
<module>pdf-umbenenner-adapter-out</module>
<module>pdf-umbenenner-bootstrap</module>
</modules>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<!-- Library versions -->
<log4j.version>2.23.1</log4j.version>
<pdfbox.version>3.0.2</pdfbox.version>
<sqlite-jdbc.version>3.45.1.0</sqlite-jdbc.version>
<json.version>20240303</json.version>
<junit.version>5.10.2</junit.version>
<mockito.version>5.11.0</mockito.version>
<!-- Plugin versions -->
<maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
<maven-surefire-plugin.version>3.2.5</maven-surefire-plugin.version>
<maven-enforcer-plugin.version>3.4.1</maven-enforcer-plugin.version>
<maven-shade-plugin.version>3.6.0</maven-shade-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Logging -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>${log4j.version}</version>
</dependency>
<!-- PDF processing -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>${sqlite-jdbc.version}</version>
</dependency>
<!-- JSON -->
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>${json.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>21</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${maven-enforcer-plugin.version}</version>
<executions>
<execution>
<id>enforce-java</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireMavenVersion>
<version>[3.9,)</version>
</requireMavenVersion>
<requireJavaVersion>
<version>[21,)</version>
</requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>${maven-shade-plugin.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>