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:
+106
-77
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
+16
-43
@@ -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)");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user