Konsolidiere Titelzeichen-Validierung in TitleCharacterRule

Doppelte private isAllowedTitleCharacters-Methoden in AiResponseValidator
und TargetFilenameBuildingService werden durch eine kanonische
TitleCharacterRule.isAllowed()-Methode ersetzt. Beide Services delegieren
jetzt dorthin statt eigene Kopien zu pflegen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 07:32:41 +02:00
parent 2e6d0b1d6d
commit d1cffe8ef9
6 changed files with 153 additions and 37 deletions
@@ -100,7 +100,7 @@ public final class AiResponseValidator {
AiErrorClassification.FUNCTIONAL); AiErrorClassification.FUNCTIONAL);
} }
if (!isAllowedTitleCharacters(title)) { if (!TitleCharacterRule.isAllowed(title)) {
return AiValidationResult.invalid( return AiValidationResult.invalid(
"Title contains disallowed characters (only letters, digits, spaces, and hyphens are permitted): '" "Title contains disallowed characters (only letters, digits, spaces, and hyphens are permitted): '"
+ title + "'", + title + "'",
@@ -137,25 +137,6 @@ public final class AiResponseValidator {
return AiValidationResult.valid(proposal); return AiValidationResult.valid(proposal);
} }
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Returns {@code true} if every character in the title is a letter, digit, space, or hyphen.
* <p>
* 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. * Returns {@code true} if the title is a known generic placeholder.
* Comparison is case-insensitive. * Comparison is case-insensitive.
@@ -149,7 +149,7 @@ public final class TargetFilenameBuildingService {
} }
// After cleaning, verify that only letters, digits, spaces, and hyphens remain // After cleaning, verify that only letters, digits, spaces, and hyphens remain
if (!isAllowedTitleCharacters(cleanedTitle)) { if (!TitleCharacterRule.isAllowed(cleanedTitle)) {
return new InconsistentProposalState( return new InconsistentProposalState(
"After Windows-compatibility cleaning, title contains disallowed characters " "After Windows-compatibility cleaning, title contains disallowed characters "
+ "(only letters, digits, spaces, and hyphens are permitted): '" + "(only letters, digits, spaces, and hyphens are permitted): '"
@@ -165,20 +165,6 @@ public final class TargetFilenameBuildingService {
// Helpers // 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. * Removes characters that are incompatible with Windows filenames.
* <p> * <p>
@@ -0,0 +1,40 @@
package de.gecheckt.pdf.umbenenner.application.service;
/**
* Canonical rule for allowed title characters.
* <p>
* A title may contain only Unicode letters (including German Umlauts and ß),
* decimal digits, ASCII spaces, and hyphens ({@code -}).
* All other characters are considered disallowed.
* <p>
* 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 -}).
* <p>
* 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;
}
}
@@ -22,6 +22,8 @@
* two-level persistence</li> * two-level persistence</li>
* <li>{@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer} * <li>{@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer}
* — Deterministic composition of AI request representations from prompt and document text</li> * — Deterministic composition of AI request representations from prompt and document text</li>
* <li>{@link de.gecheckt.pdf.umbenenner.application.service.TitleCharacterRule}
* — Canonical character-allowlist rule for document titles (single authoritative implementation)</li>
* </ul> * </ul>
* *
* <h2>Document processing flow ({@code DocumentProcessingCoordinator})</h2> * <h2>Document processing flow ({@code DocumentProcessingCoordinator})</h2>
@@ -21,7 +21,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* Unit tests for {@link TargetFilenameBuildingService}. * Unit tests for {@link TargetFilenameBuildingService}.
* <p> * <p>
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 60-character * 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 * Windows-compatibility character removal, and the detection of inconsistent persistence
* states. * states.
*/ */
@@ -267,7 +267,7 @@ class TargetFilenameBuildingServiceTest {
@Test @Test
void buildBaseFilename_validTitleProducesWindowsCompatibleFilename() { 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 // correct Windows-compatible filenames
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 5, 20), "Versicherung"); ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 5, 20), "Versicherung");
@@ -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}.
* <p>
* 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();
}
}