1
0

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
/**
* Bootstrap module for application startup and technical wiring.
* <p>
* This package contains the main entry point and manual object graph construction.
* AP-003: Provides a minimal, controlled startup path without dependency injection frameworks.
*/
package de.gecheckt.pdf.umbenenner.bootstrap;

View File

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

View File

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

View File

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

View File

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

View File

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