From ea83f8fa8cb563ebd1233e1fbea77c769126f277 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 31 Mar 2026 14:04:47 +0200 Subject: [PATCH] =?UTF-8?q?M1=20Vollst=C3=A4ndiger=20Grundstand=20mit=20Bu?= =?UTF-8?q?ild,=20Konfiguration,=20Tests=20und=20Smoke-Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 73 ++ config/application-local.example.properties | 21 + config/application-test.example.properties | 21 + config/prompts/template.txt | 1 + pdf-umbenenner-adapter-in-cli/pom.xml | 42 ++ .../umbenenner/adapter/inbound/cli/.gitkeep | 1 + .../inbound/cli/SchedulerBatchCommand.java | 34 + .../adapter/inbound/cli/package-info.java | 7 + .../src/test/java/.gitkeep | 1 + .../src/test/resources/.gitkeep | 1 + pdf-umbenenner-adapter-out/pom.xml | 56 ++ .../pdf/umbenenner/adapter/outbound/.gitkeep | 1 + .../PropertiesConfigurationPortAdapter.java | 165 +++++ .../outbound/configuration/package-info.java | 5 + .../adapter/outbound/package-info.java | 8 + .../src/test/java/.gitkeep | 1 + ...ropertiesConfigurationPortAdapterTest.java | 295 ++++++++ .../src/test/resources/.gitkeep | 1 + .../resources/missing-required.properties | 11 + .../src/test/resources/no-api-key.properties | 10 + .../test/resources/valid-config.properties | 14 + pdf-umbenenner-application/pom.xml | 43 ++ .../pdf/umbenenner/application/.gitkeep | 1 + .../InvalidStartConfigurationException.java | 21 + .../config/StartConfiguration.java | 26 + .../config/StartConfigurationValidator.java | 198 +++++ .../application/config/package-info.java | 5 + .../umbenenner/application/package-info.java | 5 + .../port/in/RunBatchProcessingUseCase.java | 18 + .../application/port/in/package-info.java | 5 + .../port/out/ConfigurationPort.java | 17 + .../application/port/out/package-info.java | 5 + .../NoOpRunBatchProcessingUseCase.java | 41 ++ .../application/usecase/package-info.java | 5 + .../src/test/java/.gitkeep | 1 + .../StartConfigurationValidatorTest.java | 686 ++++++++++++++++++ .../src/test/resources/.gitkeep | 1 + pdf-umbenenner-bootstrap/pom.xml | 104 +++ .../umbenenner/bootstrap/BootstrapRunner.java | 143 ++++ .../bootstrap/PdfUmbenennerApplication.java | 37 + .../umbenenner/bootstrap/package-info.java | 7 + .../src/main/resources/log4j2.xml | 28 + .../src/test/java/.gitkeep | 1 + .../bootstrap/BootstrapRunnerTest.java | 201 +++++ .../bootstrap/ExecutableJarSmokeTestIT.java | 275 +++++++ .../src/test/resources/.gitkeep | 1 + pdf-umbenenner-domain/pom.xml | 20 + .../gecheckt/pdf/umbenenner/domain/.gitkeep | 1 + .../pdf/umbenenner/domain/package-info.java | 7 + pdf-umbenenner-domain/src/test/java/.gitkeep | 1 + .../src/test/resources/.gitkeep | 1 + pom.xml | 145 ++++ 52 files changed, 2819 insertions(+) create mode 100644 .gitignore create mode 100644 config/application-local.example.properties create mode 100644 config/application-test.example.properties create mode 100644 config/prompts/template.txt create mode 100644 pdf-umbenenner-adapter-in-cli/pom.xml create mode 100644 pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/.gitkeep create mode 100644 pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java create mode 100644 pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java create mode 100644 pdf-umbenenner-adapter-in-cli/src/test/java/.gitkeep create mode 100644 pdf-umbenenner-adapter-in-cli/src/test/resources/.gitkeep create mode 100644 pdf-umbenenner-adapter-out/pom.xml create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/.gitkeep create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapter.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/package-info.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/package-info.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/.gitkeep create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapterTest.java create mode 100644 pdf-umbenenner-adapter-out/src/test/resources/.gitkeep create mode 100644 pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties create mode 100644 pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties create mode 100644 pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties create mode 100644 pdf-umbenenner-application/pom.xml create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/.gitkeep create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/InvalidStartConfigurationException.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfiguration.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidator.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/package-info.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/package-info.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java create mode 100644 pdf-umbenenner-application/src/test/java/.gitkeep create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidatorTest.java create mode 100644 pdf-umbenenner-application/src/test/resources/.gitkeep create mode 100644 pdf-umbenenner-bootstrap/pom.xml create mode 100644 pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java create mode 100644 pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java create mode 100644 pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java create mode 100644 pdf-umbenenner-bootstrap/src/main/resources/log4j2.xml create mode 100644 pdf-umbenenner-bootstrap/src/test/java/.gitkeep create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java create mode 100644 pdf-umbenenner-bootstrap/src/test/resources/.gitkeep create mode 100644 pdf-umbenenner-domain/pom.xml create mode 100644 pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/.gitkeep create mode 100644 pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java create mode 100644 pdf-umbenenner-domain/src/test/java/.gitkeep create mode 100644 pdf-umbenenner-domain/src/test/resources/.gitkeep create mode 100644 pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e21bf11 --- /dev/null +++ b/.gitignore @@ -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* \ No newline at end of file diff --git a/config/application-local.example.properties b/config/application-local.example.properties new file mode 100644 index 0000000..6c7b3f8 --- /dev/null +++ b/config/application-local.example.properties @@ -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 diff --git a/config/application-test.example.properties b/config/application-test.example.properties new file mode 100644 index 0000000..1eacd42 --- /dev/null +++ b/config/application-test.example.properties @@ -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 diff --git a/config/prompts/template.txt b/config/prompts/template.txt new file mode 100644 index 0000000..68422d2 --- /dev/null +++ b/config/prompts/template.txt @@ -0,0 +1 @@ +This is a test prompt template for AP-006 validation. diff --git a/pdf-umbenenner-adapter-in-cli/pom.xml b/pdf-umbenenner-adapter-in-cli/pom.xml new file mode 100644 index 0000000..cd1eab0 --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + + pdf-umbenenner-adapter-in-cli + jar + + + + + de.gecheckt + pdf-umbenenner-application + ${project.version} + + + de.gecheckt + pdf-umbenenner-domain + ${project.version} + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/.gitkeep b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java new file mode 100644 index 0000000..fe8c2af --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/SchedulerBatchCommand.java @@ -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. + *

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

+ * AP-003: Delegates to the use case without any additional logic. + * + * @return true if execution succeeded, false otherwise + */ + public boolean run() { + return useCase.execute(); + } +} \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java new file mode 100644 index 0000000..35a6ad4 --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/src/main/java/de/gecheckt/pdf/umbenenner/adapter/inbound/cli/package-info.java @@ -0,0 +1,7 @@ +/** + * CLI adapter for inbound commands. + * This package contains the technical entry points that delegate to application use cases. + *

+ * AP-003: Contains minimal command classes for validating the startup path. + */ +package de.gecheckt.pdf.umbenenner.adapter.inbound.cli; \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/test/java/.gitkeep b/pdf-umbenenner-adapter-in-cli/src/test/java/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/src/test/java/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-adapter-in-cli/src/test/resources/.gitkeep b/pdf-umbenenner-adapter-in-cli/src/test/resources/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-adapter-in-cli/src/test/resources/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/pom.xml b/pdf-umbenenner-adapter-out/pom.xml new file mode 100644 index 0000000..bd4ef3b --- /dev/null +++ b/pdf-umbenenner-adapter-out/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + + pdf-umbenenner-adapter-out + jar + + + + + de.gecheckt + pdf-umbenenner-application + ${project.version} + + + de.gecheckt + pdf-umbenenner-domain + ${project.version} + + + + + org.apache.pdfbox + pdfbox + + + org.xerial + sqlite-jdbc + + + org.json + json + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/.gitkeep b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapter.java new file mode 100644 index 0000000..5d23b02 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapter.java @@ -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 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. + *

+ * 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 environmentLookup) { + this(environmentLookup, Paths.get(DEFAULT_CONFIG_FILE_PATH)); + } + + /** + * Creates the adapter with a custom config file path. + *

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

+ * 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 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); + } + } +} \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/package-info.java new file mode 100644 index 0000000..6a8d3c5 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/package-info.java @@ -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; \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/package-info.java new file mode 100644 index 0000000..2eef182 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/outbound/package-info.java @@ -0,0 +1,8 @@ +/** + * Outbound adapters for infrastructure interactions. + *

+ * 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; \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/java/.gitkeep b/pdf-umbenenner-adapter-out/src/test/java/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapterTest.java new file mode 100644 index 0000000..331e92f --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/configuration/PropertiesConfigurationPortAdapterTest.java @@ -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}. + *

+ * Tests cover valid configuration loading, missing mandatory properties, + * invalid property values, and API-key environment variable precedence. + */ +class PropertiesConfigurationPortAdapterTest { + + private Function 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 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 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 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 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; + } +} \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/resources/.gitkeep b/pdf-umbenenner-adapter-out/src/test/resources/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/resources/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties b/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties new file mode 100644 index 0000000..23d1253 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties @@ -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 \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties b/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties new file mode 100644 index 0000000..9f9ec6d --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties @@ -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 \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties b/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties new file mode 100644 index 0000000..d6bef88 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties @@ -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 \ No newline at end of file diff --git a/pdf-umbenenner-application/pom.xml b/pdf-umbenenner-application/pom.xml new file mode 100644 index 0000000..5373e99 --- /dev/null +++ b/pdf-umbenenner-application/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + + pdf-umbenenner-application + jar + + + + + de.gecheckt + pdf-umbenenner-domain + ${project.version} + + + + + org.apache.logging.log4j + log4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/.gitkeep b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/InvalidStartConfigurationException.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/InvalidStartConfigurationException.java new file mode 100644 index 0000000..8d844c8 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/InvalidStartConfigurationException.java @@ -0,0 +1,21 @@ +package de.gecheckt.pdf.umbenenner.application.config; + +/** + * Exception thrown when startup configuration validation fails. + *

+ * 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); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfiguration.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfiguration.java new file mode 100644 index 0000000..379bccf --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfiguration.java @@ -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 +) +{ } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidator.java new file mode 100644 index 0000000..1144609 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidator.java @@ -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. + *

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

+ * 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 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 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 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 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 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 errors) { + if (apiModel == null || apiModel.isBlank()) { + errors.add("- api.model: must not be null or blank"); + } + } + + private void validateApiTimeoutSeconds(int apiTimeoutSeconds, List errors) { + if (apiTimeoutSeconds <= 0) { + errors.add("- api.timeoutSeconds: must be > 0, got: " + apiTimeoutSeconds); + } + } + + private void validateMaxRetriesTransient(int maxRetriesTransient, List errors) { + if (maxRetriesTransient < 0) { + errors.add("- max.retries.transient: must be >= 0, got: " + maxRetriesTransient); + } + } + + private void validateMaxPages(int maxPages, List errors) { + if (maxPages <= 0) { + errors.add("- max.pages: must be > 0, got: " + maxPages); + } + } + + private void validateMaxTextCharacters(int maxTextCharacters, List errors) { + if (maxTextCharacters <= 0) { + errors.add("- max.text.characters: must be > 0, got: " + maxTextCharacters); + } + } + + private void validatePromptTemplateFile(Path promptTemplateFile, List 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 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 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 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 + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/package-info.java new file mode 100644 index 0000000..8354e70 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/package-info.java @@ -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; \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/package-info.java new file mode 100644 index 0000000..dfbbeb4 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/package-info.java @@ -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; \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java new file mode 100644 index 0000000..7d3f74b --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunBatchProcessingUseCase.java @@ -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. + *

+ * AP-003 Implementation: Currently a no-op placeholder to establish the technical startup path. + */ +public interface RunBatchProcessingUseCase { + /** + * Executes the batch processing workflow. + *

+ * AP-003: This method performs no actual work, only validates the call chain. + * + * @return true if the workflow completed successfully, false otherwise + */ + boolean execute(); +} \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java new file mode 100644 index 0000000..43313f4 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java @@ -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; \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationPort.java new file mode 100644 index 0000000..81f0dde --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationPort.java @@ -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(); +} \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java new file mode 100644 index 0000000..f98a407 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java @@ -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; \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java new file mode 100644 index 0000000..f516918 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/NoOpRunBatchProcessingUseCase.java @@ -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}. + *

+ * AP-003 Implementation: Provides a controlled, non-functional startup path + * without any business logic, PDF processing, or infrastructure access. + *

+ * 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; + } +} \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java new file mode 100644 index 0000000..783813b --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/package-info.java @@ -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; \ No newline at end of file diff --git a/pdf-umbenenner-application/src/test/java/.gitkeep b/pdf-umbenenner-application/src/test/java/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidatorTest.java new file mode 100644 index 0000000..7006dc1 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/config/StartConfigurationValidatorTest.java @@ -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}. + *

+ * 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")); + } +} \ No newline at end of file diff --git a/pdf-umbenenner-application/src/test/resources/.gitkeep b/pdf-umbenenner-application/src/test/resources/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-application/src/test/resources/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/pom.xml b/pdf-umbenenner-bootstrap/pom.xml new file mode 100644 index 0000000..b1075af --- /dev/null +++ b/pdf-umbenenner-bootstrap/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + + pdf-umbenenner-bootstrap + jar + + + + + de.gecheckt + pdf-umbenenner-domain + ${project.version} + + + de.gecheckt + pdf-umbenenner-application + ${project.version} + + + de.gecheckt + pdf-umbenenner-adapter-in-cli + ${project.version} + + + de.gecheckt + pdf-umbenenner-adapter-out + ${project.version} + + + + + org.apache.logging.log4j + log4j-api + + + org.apache.logging.log4j + log4j-core + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication + + + false + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-surefire-plugin.version} + + + + integration-test + verify + + + + + + + \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java new file mode 100644 index 0000000..3020207 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -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. + *

+ * AP-003 Implementation: Creates all required components using plain Java constructor injection + * and executes the minimal no-op batch processing path. + *

+ * AP-005: Integrates configuration loading via PropertiesConfigurationPortAdapter. + *

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

+ * 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; + } + } +} \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java new file mode 100644 index 0000000..96e3d7c --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java @@ -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. + *

+ * 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); + } + } +} \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java new file mode 100644 index 0000000..81a0f59 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java @@ -0,0 +1,7 @@ +/** + * Bootstrap module for application startup and technical wiring. + *

+ * 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; \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/main/resources/log4j2.xml b/pdf-umbenenner-bootstrap/src/main/resources/log4j2.xml new file mode 100644 index 0000000..4603f07 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/resources/log4j2.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pdf-umbenenner-bootstrap/src/test/java/.gitkeep b/pdf-umbenenner-bootstrap/src/test/java/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java new file mode 100644 index 0000000..c66b358 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java @@ -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}. + *

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

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

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

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

+ * 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 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 + ); + } +} \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/test/resources/.gitkeep b/pdf-umbenenner-bootstrap/src/test/resources/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/resources/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-domain/pom.xml b/pdf-umbenenner-domain/pom.xml new file mode 100644 index 0000000..949ce93 --- /dev/null +++ b/pdf-umbenenner-domain/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + + pdf-umbenenner-domain + jar + + + + + org.junit.jupiter + junit-jupiter + test + + + \ No newline at end of file diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/.gitkeep b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java new file mode 100644 index 0000000..9d8192a --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/package-info.java @@ -0,0 +1,7 @@ +/** + * Domain layer containing business entities and value objects. + *

+ * This package is infrastructure-agnostic and contains no dependencies on external systems. + * AP-003: Currently empty as no domain logic has been implemented yet. + */ +package de.gecheckt.pdf.umbenenner.domain; \ No newline at end of file diff --git a/pdf-umbenenner-domain/src/test/java/.gitkeep b/pdf-umbenenner-domain/src/test/java/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-domain/src/test/java/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pdf-umbenenner-domain/src/test/resources/.gitkeep b/pdf-umbenenner-domain/src/test/resources/.gitkeep new file mode 100644 index 0000000..18f8c09 --- /dev/null +++ b/pdf-umbenenner-domain/src/test/resources/.gitkeep @@ -0,0 +1 @@ +# Keep directory \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8a260ce --- /dev/null +++ b/pom.xml @@ -0,0 +1,145 @@ + + + 4.0.0 + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + pom + + pdf-umbenenner-domain + pdf-umbenenner-application + pdf-umbenenner-adapter-in-cli + pdf-umbenenner-adapter-out + pdf-umbenenner-bootstrap + + + UTF-8 + 21 + 21 + 21 + + + 2.23.1 + 3.0.2 + 3.45.1.0 + 20240303 + 5.10.2 + 5.11.0 + + + 3.13.0 + 3.2.5 + 3.4.1 + 3.6.0 + + + + + + + org.apache.logging.log4j + log4j-api + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + + + + + org.apache.pdfbox + pdfbox + ${pdfbox.version} + + + + + org.xerial + sqlite-jdbc + ${sqlite-jdbc.version} + + + + + org.json + json + ${json.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + 21 + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven-enforcer-plugin.version} + + + enforce-java + + enforce + + + + + [3.9,) + + + [21,) + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + + + \ No newline at end of file