diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java index 7a6c07e..dbb0c88 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java @@ -753,6 +753,61 @@ class SqliteProcessingAttemptRepositoryAdapterTest { assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY); } + // ------------------------------------------------------------------------- + // AI field persistence is independent of logging configuration + // ------------------------------------------------------------------------- + + /** + * Verifies that the repository always stores the complete AI raw response and reasoning, + * independent of any logging sensitivity configuration. + *

+ * The {@code AiContentSensitivity} setting controls only whether sensitive content is + * written to log files. It has no influence on what the repository persists. This test + * demonstrates that full AI fields are stored regardless of any logging configuration by + * verifying a round-trip with both full content and long reasoning text. + */ + @Test + void save_persistsFullAiResponseAndReasoning_unaffectedByLoggingConfiguration() { + // The repository has no dependency on AiContentSensitivity. + // It always stores the complete AI raw response and reasoning. + DocumentFingerprint fingerprint = new DocumentFingerprint( + "d1d2d3d4d5d6d7d8d9dadbdcdddedfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfd0".substring(0, 64)); + RunId runId = new RunId("persistence-independence-run"); + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // Deliberately long and complete AI raw response — must be stored in full + String fullRawResponse = "{\"date\":\"2026-03-01\",\"title\":\"Stromabrechnung\"," + + "\"reasoning\":\"Invoice date clearly stated on page 1. Utility provider named.\"}"; + // Deliberately complete reasoning — must be stored in full + String fullReasoning = "Invoice date clearly stated on page 1. Utility provider named."; + + insertDocumentRecord(fingerprint); + + ProcessingAttempt attempt = new ProcessingAttempt( + fingerprint, runId, 1, now, now.plusSeconds(5), + ProcessingStatus.PROPOSAL_READY, + null, null, false, + "gpt-4o", "prompt-v1.txt", + 3, 750, + fullRawResponse, + fullReasoning, + LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED, + "Stromabrechnung", + null + ); + + repository.save(attempt); + + List saved = repository.findAllByFingerprint(fingerprint); + assertThat(saved).hasSize(1); + ProcessingAttempt result = saved.get(0); + + // Full raw response is stored completely — not truncated, not suppressed + assertThat(result.aiRawResponse()).isEqualTo(fullRawResponse); + // Full reasoning is stored completely — not truncated, not suppressed + assertThat(result.aiReasoning()).isEqualTo(fullReasoning); + } + // ------------------------------------------------------------------------- // Integration with document records (FK constraints) // ------------------------------------------------------------------------- diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/Log4jProcessingLoggerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/Log4jProcessingLoggerTest.java index 8e77938..898f905 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/Log4jProcessingLoggerTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/Log4jProcessingLoggerTest.java @@ -2,16 +2,13 @@ package de.gecheckt.pdf.umbenenner.bootstrap.adapter; import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.mockito.ArgumentCaptor; import java.io.IOException; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; /** * Unit tests for {@link Log4jProcessingLogger}. @@ -23,6 +20,7 @@ import static org.mockito.Mockito.*; class Log4jProcessingLoggerTest { private Log4jProcessingLogger logger; + private TestLogCapture logCapture; @BeforeEach void setUp() { @@ -30,6 +28,14 @@ class Log4jProcessingLoggerTest { logger = new Log4jProcessingLogger(Log4jProcessingLoggerTest.class); } + @AfterEach + void tearDownCapture() { + if (logCapture != null) { + logCapture.stopCapture(); + logCapture = null; + } + } + @Test void constructor_createsLoggerForClass() { // Verify that a logger is successfully created for the given class @@ -240,202 +246,99 @@ class Log4jProcessingLoggerTest { }, "Constructor should reject null AiContentSensitivity"); } - // ===== Behavioral tests: Logging sensitivity enforcement ===== + // ===== Behavioral tests: real log capture verification ===== /** - * Verifies that debugSensitiveAiContent() respects PROTECT_SENSITIVE_CONTENT. - *

- * This test demonstrates that when PROTECT_SENSITIVE_CONTENT is active, - * the method can be called without error, and the implementation respects - * the protection setting by not emitting log output. + * Verifies that in PROTECT_SENSITIVE_CONTENT mode, the raw AI response is NOT written to any log. + * Uses {@link TestLogCapture} to prove suppression with real log output. */ @Test - void debugSensitiveAiContent_protectionMode_acceptsCall() { + void debugSensitiveAiContent_protectionMode_rawResponseNotLogged() { Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( Log4jProcessingLoggerTest.class, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); + String sensitiveContent = "PROTECT_RAW_RESPONSE_9f3e2b1a"; - // Act: Call with sensitive content in protection mode - String sensitiveContent = "Complete AI response: {\"title\": \"Stromrechnung\"}"; - assertDoesNotThrow(() -> { - protectedLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent); - }, "Protection mode should accept debugSensitiveAiContent() calls without error"); - - // The actual protection check happens inside Log4jProcessingLogger.debugSensitiveAiContent(): - // it checks: if (aiContentSensitivity == AiContentSensitivity.LOG_SENSITIVE_CONTENT) - // In protection mode, the log4jLogger.debug() is NOT called. - } - - /** - * Verifies that debugSensitiveAiContent() respects LOG_SENSITIVE_CONTENT. - *

- * This test demonstrates that when LOG_SENSITIVE_CONTENT is explicitly enabled, - * the debugSensitiveAiContent() method works identically and the implementation - * logs the content by delegating to Log4j2 at DEBUG level. - */ - @Test - void debugSensitiveAiContent_explicitOptIn_acceptsCall() { - Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.LOG_SENSITIVE_CONTENT); - - // Act: Call with sensitive content in opt-in mode - String sensitiveContent = "Complete AI response: {\"title\": \"Stromrechnung\"}"; - assertDoesNotThrow(() -> { - optInLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent); - }, "Opt-in mode should accept debugSensitiveAiContent() calls without error"); - - // The actual logging happens inside Log4jProcessingLogger.debugSensitiveAiContent(): - // it checks: if (aiContentSensitivity == AiContentSensitivity.LOG_SENSITIVE_CONTENT) - // In opt-in mode, log4jLogger.debug(message, args) IS called. - } - - /** - * Verifies that the reasoning field is also protected by the sensitivity rule. - *

- * This test ensures that not only the raw AI response but also the reasoning - * field is subject to the same protection/opt-in behavior. - */ - @Test - void debugSensitiveAiContent_reasoning_respectsSensitivity() { - // Test both modes - AiContentSensitivity[] modes = { - AiContentSensitivity.PROTECT_SENSITIVE_CONTENT, - AiContentSensitivity.LOG_SENSITIVE_CONTENT - }; - - for (AiContentSensitivity mode : modes) { - // Create logger in the current mode - Log4jProcessingLogger logger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - mode); - - // Verify that the method exists and is callable - // The actual sensitivity decision is made inside debugSensitiveAiContent() - assertDoesNotThrow(() -> { - logger.debugSensitiveAiContent("AI reasoning: {}", "reasoning text"); - }, "Both sensitivity modes should accept reasoning messages"); - } - } - - /** - * Verifies that non-sensitive info/debug methods are NOT controlled by AiContentSensitivity. - *

- * This test ensures that the sensitivity rule applies ONLY to debugSensitiveAiContent(), - * not to regular debug() or info() methods. - */ - @Test - void regularDebugMethod_notAffectedBySensitivitySetting() { - // Create loggers in both modes - Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); - Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.LOG_SENSITIVE_CONTENT); - - // Both should accept regular debug() calls - assertDoesNotThrow(() -> { - protectedLogger.debug("Regular message in protection mode"); - optInLogger.debug("Regular message in opt-in mode"); - }, "Regular debug() should work regardless of sensitivity setting"); - - // Both should accept regular info() calls - assertDoesNotThrow(() -> { - protectedLogger.info("Info in protection mode"); - optInLogger.info("Info in opt-in mode"); - }, "Regular info() should work regardless of sensitivity setting"); - } - - /** - * Verifies that SQLite persistence is independent of logging sensitivity. - *

- * This test documents that the logging sensitivity rule does NOT affect - * whether sensitive content is persisted. Sensitive content remains in SQLite - * regardless of the log.ai.sensitive setting. - *

- * Note: This is a documentation test that verifies the interface contract. - * The actual SQLite persistence is verified in integration tests. - */ - @Test - void sensitivityRule_doesNotAffectSqlitePersistence() { - // Verify that both protection and opt-in modes exist - Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); - Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.LOG_SENSITIVE_CONTENT); - - // The interface contract states that both loggers must be creatable - assertNotNull(protectedLogger, "Protect mode logger must be creatable"); - assertNotNull(optInLogger, "Opt-in mode logger must be creatable"); - - // Both have the debugSensitiveAiContent method available - // The actual persistence is handled independently in the repository layer - // This test verifies that the logger API is consistent regardless of sensitivity - } - - // ===== Behavioral tests with Mockito spy verification ===== - - /** - * Verifies that in protection mode, debugSensitiveAiContent() does NOT call the underlying logger. - *

- * This test uses Mockito to spy on the Log4j2 logger and verify that the debug() method - * is never called in protection mode. - */ - @Test - void debugSensitiveAiContent_protectionMode_doesNotCallLog4jDebug() { - // Setup: Create logger with spied Log4j2 logger - Logger spiedLog4jLogger = spy(LogManager.getLogger(Log4jProcessingLoggerTest.class)); - Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.PROTECT_SENSITIVE_CONTENT) { - // Override to use spied logger - private final Logger log4jLoggerInstance = spiedLog4jLogger; - }; - - // Act: Call debugSensitiveAiContent with sensitive content - String sensitiveContent = "{\"title\": \"Stromrechnung\"}"; + logCapture = new TestLogCapture(); + logCapture.startCapture(); protectedLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent); + logCapture.stopCapture(); - // Assert: The underlying Log4j debug() should never be called in protection mode - // This verifies the BEHAVIOR: protection mode suppresses the log call + assertFalse(logCapture.containsMessage(sensitiveContent), + "PROTECT_SENSITIVE_CONTENT mode must not write raw AI response to log"); } /** - * Verifies that in opt-in mode, debugSensitiveAiContent() calls the underlying logger. - *

- * This test uses logic to verify that LOG_SENSITIVE_CONTENT mode allows the debug call. + * Verifies that in LOG_SENSITIVE_CONTENT mode, the raw AI response IS written to log. + * Uses {@link TestLogCapture} to prove emission with real log output. */ @Test - void debugSensitiveAiContent_optInMode_implementationCallsDebug() { - // Setup: Create logger in opt-in mode + void debugSensitiveAiContent_optInMode_rawResponseLogged() { Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( Log4jProcessingLoggerTest.class, AiContentSensitivity.LOG_SENSITIVE_CONTENT); + String sensitiveContent = "OPTIN_RAW_RESPONSE_7d4c8f2e"; - // Act: Call debugSensitiveAiContent - String sensitiveContent = "{\"title\": \"Stromrechnung\"}"; - // This should not throw - it should call log4jLogger.debug() - assertDoesNotThrow(() -> { - optInLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent); - }, "Opt-in mode should allow debug() calls without error"); + logCapture = new TestLogCapture(); + logCapture.startCapture(); + optInLogger.debugSensitiveAiContent("AI response: {}", sensitiveContent); + logCapture.stopCapture(); - // Assert: The fact that it didn't throw is implicit verification that - // LOG_SENSITIVE_CONTENT mode calls the underlying logger correctly + assertTrue(logCapture.containsMessage(sensitiveContent), + "LOG_SENSITIVE_CONTENT mode must write raw AI response to log when explicitly enabled"); } /** - * Verifies the critical behavioral contrast: protection mode vs. opt-in mode. - *

- * This test demonstrates that debugSensitiveAiContent() behaves differently - * depending on the AiContentSensitivity setting. With PROTECT, it does nothing. - * With LOG, it delegates to the underlying logger. + * Verifies that in PROTECT_SENSITIVE_CONTENT mode, the AI reasoning field is NOT written to log. + * Uses {@link TestLogCapture} to prove suppression of the reasoning field specifically. */ @Test - void debugSensitiveAiContent_modeContrast_behaviorDiffers() { - // Setup + void debugSensitiveAiContent_protectionMode_reasoningNotLogged() { + Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( + Log4jProcessingLoggerTest.class, + AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); + String sensitiveReasoning = "PROTECT_REASONING_c8a1e5f3"; + + logCapture = new TestLogCapture(); + logCapture.startCapture(); + protectedLogger.debugSensitiveAiContent("AI reasoning: {}", sensitiveReasoning); + logCapture.stopCapture(); + + assertFalse(logCapture.containsMessage(sensitiveReasoning), + "PROTECT_SENSITIVE_CONTENT mode must not write AI reasoning to log"); + } + + /** + * Verifies that in LOG_SENSITIVE_CONTENT mode, the AI reasoning field IS written to log. + * Uses {@link TestLogCapture} to prove emission of the reasoning field specifically. + */ + @Test + void debugSensitiveAiContent_optInMode_reasoningLogged() { + Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( + Log4jProcessingLoggerTest.class, + AiContentSensitivity.LOG_SENSITIVE_CONTENT); + String sensitiveReasoning = "OPTIN_REASONING_b2d6f9a4"; + + logCapture = new TestLogCapture(); + logCapture.startCapture(); + optInLogger.debugSensitiveAiContent("AI reasoning: {}", sensitiveReasoning); + logCapture.stopCapture(); + + assertTrue(logCapture.containsMessage(sensitiveReasoning), + "LOG_SENSITIVE_CONTENT mode must write AI reasoning to log when explicitly enabled"); + } + + /** + * Verifies the behavioral contrast between protection and opt-in mode using real log capture. + *

+ * The same sensitive content token is logged in both modes: protection mode must suppress it, + * opt-in mode must emit it. Proves the conditional in {@code debugSensitiveAiContent} works + * in both directions. + */ + @Test + void debugSensitiveAiContent_modeContrast_differentLogOutput() { + String sensitiveContent = "CONTRAST_SENSITIVE_d3e7a1b8"; + Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( Log4jProcessingLoggerTest.class, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); @@ -443,87 +346,44 @@ class Log4jProcessingLoggerTest { Log4jProcessingLoggerTest.class, AiContentSensitivity.LOG_SENSITIVE_CONTENT); - String sensitiveContent = "{\"title\": \"Test\"}"; + // Protection mode: content must NOT appear in logs + logCapture = new TestLogCapture(); + logCapture.startCapture(); + protectedLogger.debugSensitiveAiContent("AI: {}", sensitiveContent); + logCapture.stopCapture(); + assertFalse(logCapture.containsMessage(sensitiveContent), + "PROTECT mode must suppress sensitive content"); - // Act & Assert: Both should execute without error, but the BEHAVIOR is different - // In protection mode: log4jLogger.debug() is NOT called - // In opt-in mode: log4jLogger.debug() IS called - assertDoesNotThrow(() -> protectedLogger.debugSensitiveAiContent("AI: {}", sensitiveContent)); - assertDoesNotThrow(() -> optInLogger.debugSensitiveAiContent("AI: {}", sensitiveContent)); - - // The behavioral difference is encoded in the conditional inside debugSensitiveAiContent(): - // if (aiContentSensitivity == AiContentSensitivity.LOG_SENSITIVE_CONTENT) { - // log4jLogger.debug(message, args); - // } + // Opt-in mode: same content MUST appear in logs + logCapture = new TestLogCapture(); + logCapture.startCapture(); + optInLogger.debugSensitiveAiContent("AI: {}", sensitiveContent); + logCapture.stopCapture(); + assertTrue(logCapture.containsMessage(sensitiveContent), + "LOG mode must emit sensitive content"); } /** - * Verifies that regular logging methods (debug, info) are NOT affected by sensitivity setting. + * Verifies that regular {@code debug()} calls always produce log output, + * even when the logger is configured with PROTECT_SENSITIVE_CONTENT. *

- * This test ensures that only debugSensitiveAiContent() respects the sensitivity setting. + * The sensitivity rule applies only to {@code debugSensitiveAiContent()}, + * not to the regular logging methods. */ @Test - void regularLoggingMethods_notAffectedBySensitivitySetting() { - // Create logger in protection mode + void regularDebugMethod_alwaysLogged_unaffectedBySensitivity() { Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( Log4jProcessingLoggerTest.class, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); + String regularMarker = "REGULAR_DEBUG_e6f0c2d9"; - // Act: Call regular logging methods - String message = "Regular message"; - assertDoesNotThrow(() -> { - protectedLogger.info(message); - protectedLogger.debug("Debug: {}", message); - }, "Regular logging methods should work regardless of sensitivity setting"); + logCapture = new TestLogCapture(); + logCapture.startCapture(); + protectedLogger.debug("Regular: {}", regularMarker); + logCapture.stopCapture(); - // Assert: The fact that both succeeded shows they're not affected by PROTECT_SENSITIVE_CONTENT - } - - /** - * Verifies that debugSensitiveAiContent respects its setting on every call. - *

- * This test confirms that the sensitivity decision is consistently applied. - */ - @Test - void debugSensitiveAiContent_sensitivityDecisionPerCall_consistent() { - // Setup: Create a protection-mode logger - Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); - - // Act: Make multiple sensitive log calls - assertDoesNotThrow(() -> { - protectedLogger.debugSensitiveAiContent("First: {}", "sensitive-1"); - protectedLogger.debugSensitiveAiContent("Second: {}", "sensitive-2"); - protectedLogger.debugSensitiveAiContent("Third: {}", "sensitive-3"); - }, "All debugSensitiveAiContent calls should execute without error"); - - // Assert: The setting is consistently applied across all calls - // (In protection mode, all suppress the actual log output) - } - - /** - * Verifies that sensitivity setting does NOT affect SQLite persistence independently. - *

- * This test confirms that the logger only controls log file output, not persistence. - * The logger has no knowledge of or control over SQLite persistence. - */ - @Test - void sensitivityRule_doesNotAffectPersistenceDecisions() { - // Both modes should have the same method signature and behavior - Log4jProcessingLogger protectedLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.PROTECT_SENSITIVE_CONTENT); - Log4jProcessingLogger optInLogger = new Log4jProcessingLogger( - Log4jProcessingLoggerTest.class, - AiContentSensitivity.LOG_SENSITIVE_CONTENT); - - // Both have the same interface and should support the same calls - assertDoesNotThrow(() -> protectedLogger.debugSensitiveAiContent("AI: {}", "data")); - assertDoesNotThrow(() -> optInLogger.debugSensitiveAiContent("AI: {}", "data")); - - // The logger itself doesn't control persistence - that's in the repository layer. - // This test verifies the logger interface is consistent regardless of sensitivity. + assertTrue(logCapture.containsMessage(regularMarker), + "Regular debug() must always produce log output regardless of sensitivity setting"); } } diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/TestLogCapture.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/TestLogCapture.java index 4f3b3c5..6ed8d41 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/TestLogCapture.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/TestLogCapture.java @@ -1,19 +1,24 @@ package de.gecheckt.pdf.umbenenner.bootstrap.adapter; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Configuration; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.UUID; /** * Test utility that captures Log4j2 log events during test execution. *

- * This class temporarily attaches a ListAppender to the root logger - * to capture all log messages, allowing behavioral verification of logging - * decisions in unit tests. + * Registers a list appender on the root {@link org.apache.logging.log4j.core.config.LoggerConfig} + * via the Log4j2 {@link Configuration} API so that events from all descendant loggers propagate + * to the appender correctly. Call {@link #startCapture()} before the code under test and + * {@link #stopCapture()} after it to bracket the capture window. *

* Usage: *

@@ -30,34 +35,41 @@ import java.util.Objects;
 class TestLogCapture {
 
     private final ListAppender listAppender;
-    private org.apache.logging.log4j.core.Logger rootLogger;
 
     /**
-     * Creates a new TestLogCapture that will capture all log events.
+     * Creates a new TestLogCapture with a uniquely named internal appender.
      */
     TestLogCapture() {
-        this.listAppender = new ListAppender();
+        // Unique name avoids conflicts when multiple instances are created in one test run
+        this.listAppender = new ListAppender("TestListAppender-" + UUID.randomUUID());
     }
 
     /**
-     * Starts capturing log events from the root logger.
+     * Starts capturing log events by registering the appender on the root logger configuration.
      * 

- * Attaches a ListAppender to the root logger to capture all messages. + * Uses {@link Configuration#getRootLogger()} and {@link LoggerContext#updateLoggers()} so + * that events from all descendant loggers propagate through the configuration hierarchy to + * the appender. */ void startCapture() { LoggerContext context = (LoggerContext) LogManager.getContext(false); - this.rootLogger = context.getRootLogger(); - this.listAppender.start(); - this.rootLogger.addAppender(this.listAppender); + Configuration config = context.getConfiguration(); + listAppender.start(); + config.addAppender(listAppender); + config.getRootLogger().addAppender(listAppender, Level.ALL, null); + context.updateLoggers(); } /** - * Stops capturing log events and removes the appender. + * Stops capturing and removes the appender from the root logger configuration. */ void stopCapture() { - if (this.rootLogger != null) { - this.rootLogger.removeAppender(this.listAppender); - this.listAppender.stop(); + LoggerContext context = (LoggerContext) LogManager.getContext(false); + Configuration config = context.getConfiguration(); + config.getRootLogger().removeAppender(listAppender.getName()); + context.updateLoggers(); + if (listAppender.isStarted()) { + listAppender.stop(); } } @@ -69,7 +81,7 @@ class TestLogCapture { */ boolean containsMessage(String substring) { Objects.requireNonNull(substring, "substring must not be null"); - return this.listAppender.getEvents() + return listAppender.getEvents() .stream() .anyMatch(event -> event.getMessage().getFormattedMessage().contains(substring)); } @@ -80,7 +92,7 @@ class TestLogCapture { * @return list of formatted log messages */ List getMessages() { - return this.listAppender.getEvents() + return listAppender.getEvents() .stream() .map(event -> event.getMessage().getFormattedMessage()) .toList(); @@ -90,25 +102,24 @@ class TestLogCapture { * Clears all captured log events. */ void clear() { - this.listAppender.getEvents().clear(); + listAppender.clear(); } /** - * Internal ListAppender for collecting log events. + * Internal appender that collects immutable copies of log events. */ private static class ListAppender extends AbstractAppender { - private final List events = new ArrayList<>(); + private final List events = new ArrayList<>(); - ListAppender() { - super("TestListAppender", null, null, false); - // Set filter and layout to ensure events are processed + ListAppender(String name) { + super(name, null, null, false); } @Override - public void append(org.apache.logging.log4j.core.LogEvent event) { - // Copy event to ensure it's captured even if the original is reused - this.events.add(event); + public void append(LogEvent event) { + // Store an immutable copy to prevent issues with Log4j2 event object reuse + events.add(event.toImmutable()); } @Override @@ -116,8 +127,12 @@ class TestLogCapture { return false; } - List getEvents() { - return new ArrayList<>(this.events); + List getEvents() { + return new ArrayList<>(events); + } + + void clear() { + events.clear(); } } }