Fix #49: Flyway-Integration mit V1-Basisskript und 3-Fall-Strategie

Ersetzt die manuelle evolveTableColumns()-Schema-Evolution durch Flyway 10.20.1.
Die Initialisierung unterscheidet drei Faelle: leere DB (Flyway-Migration),
Bestandsschema ohne Flyway-History (Baseline nach Schema-Pruefung) und
Folgestart mit Flyway-History (idempotent). Smoke-Test-Deadlock auf Windows
durch paralleles Ausgabe-Draining des Subprozesses behoben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 11:44:28 +02:00
parent 500a8c5340
commit 732d00c4ad
9 changed files with 1145 additions and 740 deletions
@@ -5,12 +5,16 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -135,30 +139,21 @@ class ExecutableJarSmokeTestIT {
System.out.println("[SMOKE-TEST] Working directory: " + workDir.toAbsolutePath());
System.out.println("[SMOKE-TEST] Command: " + String.join(" ", command));
Process process = pb.start();
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
// 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");
System.out.println("[SMOKE-TEST] Exit code: " + result.exitCode());
System.out.println("[SMOKE-TEST] Subprocess stdout/stderr:\n" + result.output());
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);
assertTrue(result.completed(), "Process should complete within " + PROCESS_TIMEOUT_MS + "ms timeout");
assertEquals(0, result.exitCode(), "Successful startup should return exit code 0. Output was: " + result.output());
// 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
result.output().contains("Starting") ||
result.output().contains("Bootstrap") ||
result.output().contains("completed") ||
result.output().contains("successfully"),
"Output should contain startup/shutdown indicators. Got: " + result.output()
);
// Verify no unexpected artifacts were created beyond our fixtures
@@ -259,31 +254,22 @@ class ExecutableJarSmokeTestIT {
System.out.println("[SMOKE-TEST-INVALID] Working directory: " + workDir.toAbsolutePath());
System.out.println("[SMOKE-TEST-INVALID] Command: " + String.join(" ", command));
Process process = pb.start();
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
// 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");
System.out.println("[SMOKE-TEST-INVALID] Exit code: " + result.exitCode());
System.out.println("[SMOKE-TEST-INVALID] Subprocess stdout/stderr:\n" + result.output());
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(1, exitCode, "Invalid configuration should return exit code 1. Output was: " + outputText);
assertTrue(result.completed(), "Process should complete within timeout even on failure");
assertEquals(1, result.exitCode(), "Invalid configuration should return exit code 1. Output was: " + result.output());
// 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
result.output().toLowerCase().contains("config") ||
result.output().toLowerCase().contains("validation") ||
result.output().toLowerCase().contains("invalid") ||
result.output().toLowerCase().contains("error") ||
result.output().toLowerCase().contains("failed"),
"Output should indicate configuration/validation error. Got: " + result.output()
);
}
@@ -358,17 +344,14 @@ class ExecutableJarSmokeTestIT {
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Command: " + String.join(" ", command));
Process process = pb.start();
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Exit code: " + process.exitValue());
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Output:\n" + outputText);
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Exit code: " + result.exitCode());
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Output:\n" + result.output());
assertTrue(completed, "Process should complete within timeout");
assertEquals(0, process.exitValue(),
"Headless start with explicit valid --config path must exit 0. Output: " + outputText);
assertTrue(result.completed(), "Process should complete within timeout");
assertEquals(0, result.exitCode(),
"Headless start with explicit valid --config path must exit 0. Output: " + result.output());
}
// =========================================================================
@@ -403,27 +386,24 @@ class ExecutableJarSmokeTestIT {
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Command: " + String.join(" ", command));
Process process = pb.start();
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Exit code: " + process.exitValue());
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Output:\n" + outputText);
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Exit code: " + result.exitCode());
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Output:\n" + result.output());
assertTrue(completed, "Process should complete within timeout");
assertEquals(1, process.exitValue(),
"Headless start with non-existent --config path must exit 1. Output: " + outputText);
assertTrue(result.completed(), "Process should complete within timeout");
assertEquals(1, result.exitCode(),
"Headless start with non-existent --config path must exit 1. Output: " + result.output());
// Verify that the output contains a diagnostic keyword so operators can trace the cause.
// Only stable keywords are checked; exact message text may evolve.
assertTrue(
outputText.toLowerCase().contains("not found")
|| outputText.toLowerCase().contains("does not exist")
|| outputText.toLowerCase().contains("missing")
|| outputText.toLowerCase().contains("error")
|| outputText.toLowerCase().contains("config"),
"Output must contain a diagnostic keyword for the missing config file. Got: " + outputText
result.output().toLowerCase().contains("not found")
|| result.output().toLowerCase().contains("does not exist")
|| result.output().toLowerCase().contains("missing")
|| result.output().toLowerCase().contains("error")
|| result.output().toLowerCase().contains("config"),
"Output must contain a diagnostic keyword for the missing config file. Got: " + result.output()
);
}
@@ -497,30 +477,79 @@ class ExecutableJarSmokeTestIT {
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Command: " + String.join(" ", command));
Process process = pb.start();
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Exit code: " + process.exitValue());
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Output:\n" + outputText);
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Exit code: " + result.exitCode());
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Output:\n" + result.output());
assertTrue(completed, "Process should complete within timeout");
assertEquals(0, process.exitValue(),
assertTrue(result.completed(), "Process should complete within timeout");
assertEquals(0, result.exitCode(),
"Headless start must exit 0 for the JavaFX-freedom check to be meaningful. "
+ "Output: " + outputText);
+ "Output: " + result.output());
// JavaFX initialisation would produce one of these markers in stdout/stderr.
// Their absence is the evidence that the headless path is JavaFX-free at runtime.
assertFalse(
outputText.contains("Platform.startup")
|| outputText.contains("Monocle")
|| outputText.contains("com.sun.javafx")
|| outputText.contains("javafx.application"),
"Headless output must not contain JavaFX initialisation markers. Got:\n" + outputText
result.output().contains("Platform.startup")
|| result.output().contains("Monocle")
|| result.output().contains("com.sun.javafx")
|| result.output().contains("javafx.application"),
"Headless output must not contain JavaFX initialisation markers. Got:\n" + result.output()
);
}
// =========================================================================
// Shared helper: run a process and capture output concurrently
// =========================================================================
/**
* Holds the result of a subprocess execution.
*
* @param completed {@code true} if the process exited within the timeout
* @param exitCode the process exit code (meaningful only when {@code completed} is {@code true})
* @param output all bytes written to stdout/stderr by the subprocess
*/
private record ProcessResult(boolean completed, int exitCode, String output) {}
/**
* Starts the given {@link ProcessBuilder} and waits for the subprocess to finish,
* draining its combined stdout/stderr concurrently to avoid pipe-buffer deadlocks.
*
* <p>On Windows, the default OS pipe buffer is only 4 KB. If the subprocess writes
* more than that without the parent reading, the subprocess blocks on its next write
* while the parent blocks in {@code waitFor} — a classic deadlock. This helper prevents
* that by reading the subprocess output in a background thread so the pipe never fills up.
*
* @param pb configured and ready-to-start {@link ProcessBuilder}; must have
* {@code redirectErrorStream(true)} set so that stderr is merged into stdout
* @param timeoutMs maximum milliseconds to wait for the subprocess to finish
* @return a {@link ProcessResult} containing completion status, exit code, and captured output
* @throws Exception if the process cannot be started or the drain thread is interrupted
*/
private ProcessResult runProcess(ProcessBuilder pb, long timeoutMs) throws Exception {
Process process = pb.start();
// Drain stdout/stderr in a background thread to prevent Windows pipe-buffer deadlocks.
// The OS pipe buffer is only 4 KB on Windows; if the subprocess writes more than that
// while the parent is blocked in waitFor(), neither side can proceed.
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
Thread drainThread = new Thread(() -> {
try (InputStream in = process.getInputStream()) {
in.transferTo(buffer);
} catch (IOException ignored) {
// Stream closed by process exit — normal termination path
}
}, "subprocess-output-drain");
drainThread.setDaemon(true);
drainThread.start();
boolean completed = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
drainThread.join(5_000); // Allow drain to finish (process has already exited or timed out)
int exitCode = completed ? process.exitValue() : -1;
return new ProcessResult(completed, exitCode, buffer.toString());
}
// =========================================================================
// Shared helper: locate the shaded JAR
// =========================================================================
@@ -252,64 +252,37 @@ class ProviderIdentifierE2ETest {
}
// =========================================================================
// Pflicht-Testfall: legacyDataFromBeforeV11RemainsReadable
// Nicht-konformes Bestands-Schema Schema-Prüfung schlägt ab
// =========================================================================
/**
* Proves backward compatibility with databases created before the {@code ai_provider}
* column was introduced.
* Eine Datenbank, die fachliche Tabellen enthält, aber nicht dem vollständigen
* Zielschema entspricht (fehlende Spalten, fehlende Indizes), darf nicht stillschweigend
* heilen. Die Initialisierung muss mit einem klaren Fehler abbrechen.
*
* <h2>What is verified</h2>
* <ol>
* <li>A database without the {@code ai_provider} column can be opened and its existing
* rows read without throwing any exception.</li>
* <li>The {@code aiProvider} field for pre-extension rows is {@code null} (no synthesised
* default, no error).</li>
* <li>Other fields on the pre-extension attempt (status, retryable flag) remain
* correctly readable after schema evolution.</li>
* <li>A new batch run on the same database succeeds, proving that the evolved schema
* is fully write-compatible with the legacy data.</li>
* </ol>
* <p>Geprüft wird, dass die Schema-Prüfcheckliste greift: fehlen Spalten wie
* {@code ai_provider}, {@code last_target_path} oder fehlende Indizes, dann bricht
* der Start mit {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException} ab.
*/
@Test
void legacyDataFromBeforeV11RemainsReadable(@TempDir Path tempDir) throws Exception {
// Build a database without the ai_provider column (simulates pre-extension installation)
void nichtKonformesBestandsSchema_fuehrtZuFehlerBeimStart(@TempDir Path tempDir) throws Exception {
// Datenbank mit unvollständigem Schema anlegen (fehlt: ai_provider, last_target_path,
// last_target_file_name sowie alle drei Indizes)
String jdbcUrl = "jdbc:sqlite:"
+ tempDir.resolve("legacy.db").toAbsolutePath().toString().replace('\\', '/');
createPreExtensionSchema(jdbcUrl);
// Insert a legacy attempt row (no ai_provider column present in schema at this point)
// Datensatz einfügen (Schema ist noch partiell vorhanden)
DocumentFingerprint legacyFp = fingerprint("aabbcc");
insertLegacyData(jdbcUrl, legacyFp);
// Initialize the full schema — this must add ai_provider idempotently
// Initialisierung muss mit klarem Fehler abbrechen kein stilles Heilen
de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter schema =
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter(jdbcUrl);
schema.initializeSchema();
// Read back the legacy attempt — must not throw, aiProvider must be null
de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter repo =
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
List<ProcessingAttempt> attempts = repo.findAllByFingerprint(legacyFp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Pre-extension attempt must have null aiProvider after schema evolution")
.isNull();
assertThat(attempts.get(0).status())
.as("Other fields of the pre-extension row must still be readable")
.isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(attempts.get(0).retryable()).isTrue();
// A new batch run on the same database must succeed (write-compatible evolved schema)
try (E2ETestContext ctx = E2ETestContext.initializeWithProvider(
tempDir.resolve("newrun"), "openai-compatible")) {
ctx.createSearchablePdf("newdoc.pdf", SAMPLE_PDF_TEXT);
BatchRunOutcome outcome = ctx.runBatch();
assertThat(outcome)
.as("Batch run on evolved database must succeed")
.isEqualTo(BatchRunOutcome.SUCCESS);
}
org.junit.jupiter.api.Assertions.assertThrows(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
schema::initializeSchema,
"Erwarte Fehler bei nicht konformem Bestands-Schema (fehlende Spalten/Indizes)");
}
// -------------------------------------------------------------------------