diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java index 3d6b91b..901263c 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java @@ -100,7 +100,7 @@ public final class AiResponseValidator { AiErrorClassification.FUNCTIONAL); } - if (!isAllowedTitleCharacters(title)) { + if (!TitleCharacterRule.isAllowed(title)) { return AiValidationResult.invalid( "Title contains disallowed characters (only letters, digits, spaces, and hyphens are permitted): '" + title + "'", @@ -137,25 +137,6 @@ public final class AiResponseValidator { return AiValidationResult.valid(proposal); } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - /** - * Returns {@code true} if every character in the title is a letter, digit, space, or hyphen. - *
- * Permits Unicode letters including German Umlauts (ä, ö, ü, Ä, Ö, Ü) and ß. - */ - private static boolean isAllowedTitleCharacters(String title) { - for (int i = 0; i < title.length(); i++) { - char c = title.charAt(i); - if (!Character.isLetter(c) && !Character.isDigit(c) && c != ' ' && c != '-') { - return false; - } - } - return true; - } - /** * Returns {@code true} if the title is a known generic placeholder. * Comparison is case-insensitive. diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java index 09d35ca..bf271d4 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java @@ -149,7 +149,7 @@ public final class TargetFilenameBuildingService { } // After cleaning, verify that only letters, digits, spaces, and hyphens remain - if (!isAllowedTitleCharacters(cleanedTitle)) { + if (!TitleCharacterRule.isAllowed(cleanedTitle)) { return new InconsistentProposalState( "After Windows-compatibility cleaning, title contains disallowed characters " + "(only letters, digits, spaces, and hyphens are permitted): '" @@ -165,20 +165,6 @@ public final class TargetFilenameBuildingService { // Helpers // ------------------------------------------------------------------------- - /** - * Returns {@code true} if every character in the title is a letter, a digit, space, or hyphen. - * Unicode letters (including German Umlauts and ß) are permitted. - */ - private static boolean isAllowedTitleCharacters(String title) { - for (int i = 0; i < title.length(); i++) { - char c = title.charAt(i); - if (!Character.isLetter(c) && !Character.isDigit(c) && c != ' ' && c != '-') { - return false; - } - } - return true; - } - /** * Removes characters that are incompatible with Windows filenames. *
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TitleCharacterRule.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TitleCharacterRule.java new file mode 100644 index 0000000..07d2928 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TitleCharacterRule.java @@ -0,0 +1,40 @@ +package de.gecheckt.pdf.umbenenner.application.service; + +/** + * Canonical rule for allowed title characters. + *
+ * A title may contain only Unicode letters (including German Umlauts and ß), + * decimal digits, ASCII spaces, and hyphens ({@code -}). + * All other characters are considered disallowed. + *
+ * This class is the single authoritative implementation of the character-allowlist + * rule. All services that need to validate or verify title characters must delegate + * to {@link #isAllowed(String)} instead of maintaining their own copies. + */ +public final class TitleCharacterRule { + + private TitleCharacterRule() { + // utility class + } + + /** + * Returns {@code true} if every character in {@code title} is a letter, digit, + * ASCII space, or hyphen ({@code -}). + *
+ * Unicode letters — including German Umlauts (ä, ö, ü, Ä, Ö, Ü) and ß — are + * permitted. An empty string is considered allowed. + * + * @param title the title to check; must not be null + * @return {@code true} if all characters pass the allowlist; {@code false} otherwise + * @throws NullPointerException if {@code title} is null + */ + public static boolean isAllowed(String title) { + for (int i = 0; i < title.length(); i++) { + char c = title.charAt(i); + if (!Character.isLetter(c) && !Character.isDigit(c) && c != ' ' && c != '-') { + return false; + } + } + return true; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java index 0ccdf97..bf90308 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java @@ -22,6 +22,8 @@ * two-level persistence *
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 60-character - * base-title rule, the fachliche Titelregel (only letters, digits, and spaces), + * base-title rule, the fachliche Titelregel (only letters, digits, spaces, and hyphens), * Windows-compatibility character removal, and the detection of inconsistent persistence * states. */ @@ -267,7 +267,7 @@ class TargetFilenameBuildingServiceTest { @Test void buildBaseFilename_validTitleProducesWindowsCompatibleFilename() { - // Valid titles containing only letters, digits, and spaces should produce + // Valid titles containing only letters, digits, spaces, and hyphens should produce // correct Windows-compatible filenames ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 5, 20), "Versicherung"); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TitleCharacterRuleTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TitleCharacterRuleTest.java new file mode 100644 index 0000000..3a592ef --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TitleCharacterRuleTest.java @@ -0,0 +1,107 @@ +package de.gecheckt.pdf.umbenenner.application.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TitleCharacterRule}. + *
+ * Verifies the canonical character-allowlist rule: letters (including German Umlauts + * and ß), digits, ASCII spaces, and hyphens are permitted; everything else is not. + */ +class TitleCharacterRuleTest { + + // ------------------------------------------------------------------------- + // Allowed characters + // ------------------------------------------------------------------------- + + @Test + void isAllowed_lettersOnly_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("Stromabrechnung")).isTrue(); + } + + @Test + void isAllowed_lettersAndDigits_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("Rechnung 2026")).isTrue(); + } + + @Test + void isAllowed_germanUmlauts_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("Müller Überprüfung")).isTrue(); + } + + @Test + void isAllowed_germanSzlig_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("Straße")).isTrue(); + } + + @Test + void isAllowed_hyphen_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("Strom-Rechnung")).isTrue(); + } + + @Test + void isAllowed_hyphenWithSpaces_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("Kfz-Haftpflicht 2026")).isTrue(); + } + + @Test + void isAllowed_emptyString_returnsTrue() { + assertThat(TitleCharacterRule.isAllowed("")).isTrue(); + } + + // ------------------------------------------------------------------------- + // Disallowed characters + // ------------------------------------------------------------------------- + + @Test + void isAllowed_exclamationMark_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rechnung!")).isFalse(); + } + + @Test + void isAllowed_dot_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rechnung.pdf")).isFalse(); + } + + @Test + void isAllowed_colon_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Betreff: Rechnung")).isFalse(); + } + + @Test + void isAllowed_slash_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rg/Strom")).isFalse(); + } + + @Test + void isAllowed_backslash_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rg\\Strom")).isFalse(); + } + + @Test + void isAllowed_questionMark_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Was?")).isFalse(); + } + + @Test + void isAllowed_asterisk_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rech*nung")).isFalse(); + } + + @Test + void isAllowed_atSign_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("user@mail")).isFalse(); + } + + @Test + void isAllowed_parenthesis_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rechnung (2026)")).isFalse(); + } + + @Test + void isAllowed_underscore_returnsFalse() { + assertThat(TitleCharacterRule.isAllowed("Rechnung_2026")).isFalse(); + } +}