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 index 6a2270c..6277ab0 100644 --- 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 @@ -14,11 +14,57 @@ import java.util.List; * Performs mandatory field checks, numeric range validation, URI scheme validation, * and basic path existence checks. Throws {@link InvalidStartConfigurationException} * if any validation rule fails. + *

+ * M3/AP-007: Supports injected source folder validation for testability + * (allows mocking of platform-dependent filesystem checks). */ public class StartConfigurationValidator { private static final Logger LOG = LogManager.getLogger(StartConfigurationValidator.class); + /** + * Abstraction for source folder existence, type, and readability checks. + *

+ * Separates filesystem operations from validation logic to enable + * platform-independent unit testing (mocking) of readability edge cases. + *

+ * Implementation note: The default implementation uses {@code java.nio.file.Files} + * static methods directly; tests can substitute alternative implementations. + */ + @FunctionalInterface + public interface SourceFolderChecker { + /** + * Checks source folder and returns validation error message, or null if valid. + *

+ * Checks (in order): + * 1. Folder exists + * 2. Is a directory + * 3. Is readable + * + * @param path the source folder path + * @return error message string, or null if all checks pass + */ + String checkSourceFolder(Path path); + } + + private final SourceFolderChecker sourceFolderChecker; + + /** + * Creates a validator with the default source folder checker (NIO-based). + */ + public StartConfigurationValidator() { + this(new DefaultSourceFolderChecker()); + } + + /** + * Creates a validator with a custom source folder checker (primarily for testing). + * + * @param sourceFolderChecker the checker to use (must not be null) + */ + public StartConfigurationValidator(SourceFolderChecker sourceFolderChecker) { + this.sourceFolderChecker = sourceFolderChecker; + } + /** * Validates the given configuration. *

@@ -66,12 +112,9 @@ public class StartConfigurationValidator { 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); - } else if (!Files.isReadable(sourceFolder)) { - errors.add("- source.folder: directory is not readable: " + sourceFolder); + String checkError = sourceFolderChecker.checkSourceFolder(sourceFolder); + if (checkError != null) { + errors.add(checkError); } } @@ -197,4 +240,29 @@ public class StartConfigurationValidator { // If it doesn't exist yet, that's acceptable - we don't auto-create } } + + /** + * Default NIO-based implementation of {@link SourceFolderChecker}. + *

+ * Uses {@code java.nio.file.Files} static methods to check existence, type, and readability. + *

+ * M3/AP-007: This separation allows unit tests to inject alternative implementations + * that control the outcome of readability checks without relying on actual filesystem + * permissions (which are platform-dependent). + */ + private static class DefaultSourceFolderChecker implements SourceFolderChecker { + @Override + public String checkSourceFolder(Path path) { + if (!Files.exists(path)) { + return "- source.folder: path does not exist: " + path; + } + if (!Files.isDirectory(path)) { + return "- source.folder: path is not a directory: " + path; + } + if (!Files.isReadable(path)) { + return "- source.folder: directory is not readable: " + path; + } + return null; // All checks passed + } + } } 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 index 7006dc1..f8fbb81 100644 --- 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 @@ -683,4 +683,153 @@ class StartConfigurationValidatorTest { assertTrue(message.contains("max.pages: must be > 0")); assertTrue(message.contains("max.text.characters: must be > 0")); } + + /** + * M3/AP-007: Focused tests for source folder validation using mocked filesystem checks. + *

+ * These tests verify the four critical paths for source folder validation without + * relying on platform-dependent filesystem permissions or the actual FS state. + */ + + @Test + void validate_failsWhenSourceFolderDoesNotExist_mocked() 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" + ); + + // Mock: always return "does not exist" error for any path + StartConfigurationValidator.SourceFolderChecker mockChecker = path -> + "- source.folder: path does not exist: " + path; + + StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker); + + InvalidStartConfigurationException exception = assertThrows( + InvalidStartConfigurationException.class, + () -> validatorWithMock.validate(config) + ); + assertTrue(exception.getMessage().contains("source.folder: path does not exist")); + } + + @Test + void validate_failsWhenSourceFolderIsNotADirectory_mocked() 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("somepath"), + targetFolder, + sqliteFile, + URI.create("https://api.example.com"), + "gpt-4", + 30, + 3, + 100, + 50000, + promptTemplateFile, + null, + null, + "INFO", + "test-api-key" + ); + + // Mock: simulate path exists but is not a directory + StartConfigurationValidator.SourceFolderChecker mockChecker = path -> + "- source.folder: path is not a directory: " + path; + + StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker); + + InvalidStartConfigurationException exception = assertThrows( + InvalidStartConfigurationException.class, + () -> validatorWithMock.validate(config) + ); + assertTrue(exception.getMessage().contains("source.folder: path is not a directory")); + } + + @Test + void validate_failsWhenSourceFolderIsNotReadable_mocked() 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("somepath"), + targetFolder, + sqliteFile, + URI.create("https://api.example.com"), + "gpt-4", + 30, + 3, + 100, + 50000, + promptTemplateFile, + null, + null, + "INFO", + "test-api-key" + ); + + // Mock: simulate path exists, is directory, but is not readable + // This is the critical M3/AP-007 case that is hard to test on actual FS + StartConfigurationValidator.SourceFolderChecker mockChecker = path -> + "- source.folder: directory is not readable: " + path; + + StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker); + + InvalidStartConfigurationException exception = assertThrows( + InvalidStartConfigurationException.class, + () -> validatorWithMock.validate(config) + ); + assertTrue(exception.getMessage().contains("source.folder: directory is not readable")); + } + + @Test + void validate_succeedsWhenSourceFolderIsValid_mocked() 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, + null, + null, + "INFO", + "test-api-key" + ); + + // Mock: all checks pass (return null) + StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null; + + StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker); + + assertDoesNotThrow(() -> validatorWithMock.validate(config), + "Validation should succeed when source folder checker returns null"); + } } \ No newline at end of file