PIT-Lücken in bootstrap gezielt geschlossen
This commit is contained in:
@@ -83,14 +83,9 @@ public class Log4jProcessingLogger implements ProcessingLogger {
|
||||
|
||||
@Override
|
||||
public void error(String message, Object... args) {
|
||||
// If the last argument is a Throwable, extract it and pass separately
|
||||
if (args.length > 0 && args[args.length - 1] instanceof Throwable throwable) {
|
||||
Object[] messageArgs = new Object[args.length - 1];
|
||||
System.arraycopy(args, 0, messageArgs, 0, args.length - 1);
|
||||
log4jLogger.error(message, throwable, messageArgs);
|
||||
} else {
|
||||
log4jLogger.error(message, args);
|
||||
}
|
||||
// Log4j2 detects a trailing Throwable in the params array automatically:
|
||||
// it uses it as the log cause and formats only the preceding arguments.
|
||||
log4jLogger.error(message, args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,8 +8,18 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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 org.apache.logging.log4j.core.config.Property;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
@@ -339,6 +349,143 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
"logAiSensitive=true must resolve to LOG_SENSITIVE_CONTENT");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// mapOutcomeToExitCode: log level semantics
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* LOCK_UNAVAILABLE is an expected operational situation (another instance is already running)
|
||||
* and must be logged at WARN level, not ERROR. Verifies that the conditional branch
|
||||
* for {@link BatchRunOutcome#LOCK_UNAVAILABLE} is distinct from the generic failure branch.
|
||||
*/
|
||||
@Test
|
||||
void mapOutcomeToExitCode_lockUnavailableLogsAtWarnLevel() throws Exception {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
List<LogEvent> capturedEvents = new ArrayList<>();
|
||||
String appenderName = "TestCapture-LockWarn-" + UUID.randomUUID();
|
||||
|
||||
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
|
||||
Configuration cfg = ctx.getConfiguration();
|
||||
AbstractAppender captureAppender = new AbstractAppender(
|
||||
appenderName, null, null, false, Property.EMPTY_ARRAY) {
|
||||
@Override
|
||||
public void append(LogEvent event) {
|
||||
capturedEvents.add(event.toImmutable());
|
||||
}
|
||||
};
|
||||
captureAppender.start();
|
||||
cfg.addAppender(captureAppender);
|
||||
cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
|
||||
ctx.updateLoggers();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> context -> BatchRunOutcome.LOCK_UNAVAILABLE,
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
try {
|
||||
runner.run();
|
||||
} finally {
|
||||
cfg.getRootLogger().removeAppender(appenderName);
|
||||
ctx.updateLoggers();
|
||||
captureAppender.stop();
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
capturedEvents.stream().anyMatch(e ->
|
||||
e.getLevel() == Level.WARN
|
||||
&& e.getMessage().getFormattedMessage().contains("another instance")),
|
||||
"LOCK_UNAVAILABLE must produce a WARN-level log mentioning another instance is running");
|
||||
assertTrue(
|
||||
capturedEvents.stream().noneMatch(e ->
|
||||
e.getLevel() == Level.ERROR
|
||||
&& e.getMessage().getFormattedMessage().contains("another instance")),
|
||||
"LOCK_UNAVAILABLE must not produce an ERROR-level log");
|
||||
}
|
||||
|
||||
/**
|
||||
* A true batch FAILURE (infrastructure error) must be logged at ERROR level, not WARN.
|
||||
* Verifies the else branch of {@link BatchRunOutcome#FAILURE} uses the error log path.
|
||||
*/
|
||||
@Test
|
||||
void mapOutcomeToExitCode_failureLogsAtErrorLevel() throws Exception {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
List<LogEvent> capturedEvents = new ArrayList<>();
|
||||
String appenderName = "TestCapture-Failure-" + UUID.randomUUID();
|
||||
|
||||
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
|
||||
Configuration cfg = ctx.getConfiguration();
|
||||
AbstractAppender captureAppender = new AbstractAppender(
|
||||
appenderName, null, null, false, Property.EMPTY_ARRAY) {
|
||||
@Override
|
||||
public void append(LogEvent event) {
|
||||
capturedEvents.add(event.toImmutable());
|
||||
}
|
||||
};
|
||||
captureAppender.start();
|
||||
cfg.addAppender(captureAppender);
|
||||
cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
|
||||
ctx.updateLoggers();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> context -> BatchRunOutcome.FAILURE,
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
try {
|
||||
runner.run();
|
||||
} finally {
|
||||
cfg.getRootLogger().removeAppender(appenderName);
|
||||
ctx.updateLoggers();
|
||||
captureAppender.stop();
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
capturedEvents.stream().anyMatch(e ->
|
||||
e.getLevel() == Level.ERROR
|
||||
&& e.getMessage().getFormattedMessage().contains("failed")),
|
||||
"Batch FAILURE must produce an ERROR-level log mentioning the run failed");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// executeWithStartConfiguration: run context lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* The end instant on the {@link BatchRunContext} must be set after the batch command returns.
|
||||
* A missing setEndInstant call would leave the context without a completion timestamp,
|
||||
* breaking run completion logging.
|
||||
*/
|
||||
@Test
|
||||
void executeWithStartConfiguration_setsEndInstantOnBatchRunContext() throws Exception {
|
||||
AtomicReference<BatchRunContext> capturedContext = new AtomicReference<>();
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> context -> {
|
||||
capturedContext.set(context);
|
||||
return BatchRunOutcome.SUCCESS;
|
||||
},
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
|
||||
runner.run();
|
||||
|
||||
assertNotNull(capturedContext.get(), "BatchRunContext should have been passed to the use case");
|
||||
assertTrue(capturedContext.get().isCompleted(),
|
||||
"End instant must be set on BatchRunContext after the batch command completes");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -175,6 +175,23 @@ class Log4jProcessingLoggerTest {
|
||||
}, "error() should handle Throwable as non-last argument as format parameter");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that format arguments before the trailing Throwable are preserved in the
|
||||
* formatted log message. Without a correct arraycopy, the message args would be empty
|
||||
* (all nulls) and the substituted values would not appear in the log output.
|
||||
*/
|
||||
@Test
|
||||
void error_withFormatArgsAndThrowable_preservesFormatArgsInMessage() {
|
||||
String expectedArg = "EXPECTED_FORMAT_ARG_7c4a3b1e";
|
||||
logCapture = new TestLogCapture();
|
||||
logCapture.startCapture();
|
||||
logger.error("Processing failed for {}", expectedArg, new RuntimeException("test failure"));
|
||||
logCapture.stopCapture();
|
||||
|
||||
assertTrue(logCapture.containsMessage(expectedArg),
|
||||
"Format args before the trailing Throwable must appear in the formatted log message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void implementsProcessingLoggerInterface() {
|
||||
// Verify that Log4jProcessingLogger correctly implements ProcessingLogger
|
||||
|
||||
Reference in New Issue
Block a user