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

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