From cdc4963b140bec27d56ff97cbc0600fca05c0430 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Sun, 24 May 2026 16:26:06 +0100 Subject: [PATCH] refactor: add Pipeiline report listeners --- .../data/ExecutionReportFormatter.java | 272 ++++++++++++++++++ .../data/ExecutionReportFormatterTest.java | 220 ++++++++++++++ .../core/builder/AbstractBuilder.java | 11 + .../builder/AbstractChangeRunnerBuilder.java | 29 +- .../configuration/core/CoreConfigurable.java | 4 + .../configuration/core/CoreConfiguration.java | 19 ++ .../configuration/core/CoreConfigurator.java | 4 + ...efaultPipelineCompletedReportListener.java | 48 ++++ .../DefaultPipelineFailedReportListener.java | 55 ++++ .../event/model/IPipelineCompletedEvent.java | 4 + .../event/model/IPipelineFailedEvent.java | 4 +- .../event/model/IStageCompletedEvent.java | 4 +- .../core/event/model/IStageFailedEvent.java | 4 +- .../model/impl/PipelineCompletedEvent.java | 9 +- .../event/model/impl/PipelineFailedEvent.java | 10 +- .../event/model/impl/StageCompletedEvent.java | 8 +- .../event/model/impl/StageFailedEvent.java | 10 +- .../AbstractPipelineTraverseOperation.java | 19 +- .../PipelineExecuteOperationException.java | 7 + .../StagedExecuteOperationException.java | 32 +-- .../EnableDefaultExecutionReportFlagTest.java | 40 +++ ...ltPipelineCompletedReportListenerTest.java | 54 ++++ ...faultPipelineFailedReportListenerTest.java | 80 ++++++ .../StagedExecuteOperationExceptionTest.java | 90 ++++++ .../inmemory/InMemoryFlamingockBuilder.java | 6 +- docs/ERROR_REPORTING_PROPOSAL.md | 10 +- .../springboot/SpringbootProperties.java | 10 + .../event/SpringPipelineCompletedEvent.java | 6 + .../event/SpringPipelineFailedEvent.java | 6 +- .../event/SpringStageCompletedEvent.java | 10 +- .../event/SpringStageFailedEvent.java | 11 +- .../event/SpringStageIgnoredEvent.java | 5 +- .../event/SpringStageStartedEvent.java | 5 +- .../SpringEventPayloadPassthroughTest.java | 85 ++++++ .../java/io/flamingock/core/kit/TestKit.java | 6 +- 35 files changed, 1131 insertions(+), 66 deletions(-) create mode 100644 core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java create mode 100644 core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java create mode 100644 core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java create mode 100644 core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java create mode 100644 core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java create mode 100644 core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java create mode 100644 core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java create mode 100644 core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java create mode 100644 platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java new file mode 100644 index 000000000..0092f781f --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java @@ -0,0 +1,272 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.common.core.response.data; + +import io.flamingock.internal.common.core.recovery.RecoveryIssue; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Canonical renderer for {@link ExecuteResponseData}. Two outputs: + * + * + * + *

Both methods are fully defensive: null result, empty stages, missing per-stage state, and + * partial data are tolerated; the formatter never throws. The default event listener and the + * exception {@code toString()} both depend on this contract. + */ +public final class ExecutionReportFormatter { + + private static final String LINE = "========================================================================"; + private static final String NEWLINE = System.lineSeparator(); + + private ExecutionReportFormatter() { + } + + public static String summary(ExecuteResponseData result) { + if (result == null) { + return "Flamingock execution: no execution data available"; + } + + List stages = nonNullStages(result); + List failedStageNames = failedStageNames(stages); + List miChangeIds = manualInterventionChangeIds(stages); + + String headline; + if (isFailedStatus(result.getStatus()) || !failedStageNames.isEmpty()) { + headline = String.format( + "Flamingock execution failed: %d of %d stage(s) failed [%s]", + result.getFailedStages(), + result.getTotalStages(), + String.join(", ", failedStageNames)); + } else { + headline = String.format( + "Flamingock execution completed: %d stage(s)", + result.getTotalStages()); + } + + String counts = String.format( + "; changes applied=%d, failed=%d, skipped=%d; duration=%dms", + result.getAppliedChanges(), + result.getFailedChanges(), + result.getSkippedChanges(), + result.getTotalDurationMs()); + + StringBuilder sb = new StringBuilder(headline).append(counts); + if (!miChangeIds.isEmpty()) { + sb.append("; manual intervention required for change(s): ") + .append(String.join(", ", miChangeIds)); + } + return sb.toString(); + } + + public static String report(ExecuteResponseData result) { + if (result == null) { + return banner("Flamingock execution report — NO DATA"); + } + + StringBuilder sb = new StringBuilder(); + String statusLabel = statusLabel(result); + sb.append(LINE).append(NEWLINE) + .append(" Flamingock execution report — ").append(statusLabel).append(NEWLINE) + .append(LINE).append(NEWLINE); + + appendTimeBlock(sb, result); + sb.append(NEWLINE); + + sb.append(" Stages: ") + .append(result.getTotalStages()).append(" total — ") + .append(result.getCompletedStages()).append(" completed, ") + .append(result.getFailedStages()).append(" failed") + .append(NEWLINE); + sb.append(" Changes: ") + .append(result.getTotalChanges()).append(" total — ") + .append(result.getAppliedChanges()).append(" applied, ") + .append(result.getSkippedChanges()).append(" skipped, ") + .append(result.getFailedChanges()).append(" failed") + .append(NEWLINE); + + List stages = nonNullStages(result); + if (!stages.isEmpty()) { + sb.append(NEWLINE).append(" Per-stage breakdown:").append(NEWLINE).append(NEWLINE); + for (StageResult stage : stages) { + appendStageBlock(sb, stage); + sb.append(NEWLINE); + } + } + + sb.append(LINE); + return sb.toString(); + } + + private static void appendTimeBlock(StringBuilder sb, ExecuteResponseData result) { + Instant start = result.getStartTime(); + Instant end = result.getEndTime(); + sb.append(" Started: ").append(start != null ? start.toString() : "n/a").append(NEWLINE); + sb.append(" Finished: ").append(end != null ? end.toString() : "n/a").append(NEWLINE); + sb.append(" Duration: ").append(result.getTotalDurationMs()).append(" ms").append(NEWLINE); + } + + private static void appendStageBlock(StringBuilder sb, StageResult stage) { + String label = stageLabel(stage); + String name = stage.getStageName() != null ? stage.getStageName() : "(unnamed)"; + sb.append(" ").append(label).append(' ').append(name) + .append(" (").append(stage.getDurationMs()).append(" ms)").append(NEWLINE); + + List changes = stage.getChanges() != null ? stage.getChanges() : Collections.emptyList(); + int applied = (int) changes.stream().filter(c -> c != null && c.isApplied()).count(); + int skipped = (int) changes.stream().filter(c -> c != null && c.isAlreadyApplied()).count(); + int failed = (int) changes.stream().filter(c -> c != null && c.isFailed()).count(); + sb.append(" changes: ") + .append(applied).append(" applied, ") + .append(skipped).append(" skipped, ") + .append(failed).append(" failed") + .append(NEWLINE); + + StageState state = stage.getState(); + if (state == null) { + return; + } + if (state.isBlockedForManualIntervention()) { + List ids = changeIdsFromRecoveryIssues(state.getRecoveryIssues()); + if (!ids.isEmpty()) { + sb.append(" change(s) requiring intervention: ") + .append(String.join(", ", ids)).append(NEWLINE); + } + } else if (state.isFailed()) { + state.getErrorInfo().ifPresent(err -> appendErrorBlock(sb, err)); + List failedChangeIds = failedChangeIds(stage); + if (!failedChangeIds.isEmpty()) { + sb.append(" failed change(s): ") + .append(String.join(", ", failedChangeIds)).append(NEWLINE); + } + } + } + + private static void appendErrorBlock(StringBuilder sb, ErrorInfo err) { + String errorType = err.getErrorType() != null ? err.getErrorType() : "Error"; + sb.append(" error: ").append(errorType).append(NEWLINE); + String message = err.getMessage(); + if (message != null && !message.isEmpty()) { + // Indent continuation lines so multi-line messages stay aligned with the "error:" column. + String[] lines = message.split("\\R"); + for (String line : lines) { + sb.append(" ").append(line).append(NEWLINE); + } + } + } + + private static String stageLabel(StageResult stage) { + StageState state = stage.getState(); + if (state == null) { + return "[UNKNOWN] "; + } + if (state.isBlockedForManualIntervention()) { + return "[BLOCKED — manual intervention required]"; + } + if (state.isFailed()) { + return "[FAILED] "; + } + if (state.isCompleted()) { + return "[COMPLETED]"; + } + if (state.isStarted()) { + return "[STARTED] "; + } + if (state.isNotStarted()) { + return "[PENDING] "; + } + return "[UNKNOWN] "; + } + + private static String statusLabel(ExecuteResponseData result) { + ExecutionStatus status = result.getStatus(); + if (status == null) { + return "UNKNOWN"; + } + return status.name(); + } + + private static boolean isFailedStatus(ExecutionStatus status) { + return status == ExecutionStatus.FAILED; + } + + private static List nonNullStages(ExecuteResponseData result) { + List raw = result.getStages(); + if (raw == null) { + return Collections.emptyList(); + } + List filtered = new ArrayList<>(raw.size()); + for (StageResult s : raw) { + if (s != null) { + filtered.add(s); + } + } + return filtered; + } + + private static List failedStageNames(List stages) { + return stages.stream() + .filter(s -> s.getState() != null && s.getState().isFailed()) + .map(s -> s.getStageName() != null ? s.getStageName() : "(unnamed)") + .collect(Collectors.toList()); + } + + private static List manualInterventionChangeIds(List stages) { + List ids = new ArrayList<>(); + for (StageResult s : stages) { + StageState state = s.getState(); + if (state != null && state.isBlockedForManualIntervention()) { + ids.addAll(changeIdsFromRecoveryIssues(state.getRecoveryIssues())); + } + } + return ids; + } + + private static List changeIdsFromRecoveryIssues(List issues) { + if (issues == null) { + return Collections.emptyList(); + } + return issues.stream() + .filter(i -> i != null && i.getChangeId() != null) + .map(RecoveryIssue::getChangeId) + .collect(Collectors.toList()); + } + + private static List failedChangeIds(StageResult stage) { + List changes = stage.getChanges(); + if (changes == null) { + return Collections.emptyList(); + } + return changes.stream() + .filter(c -> c != null && c.isFailed() && c.getChangeId() != null) + .map(ChangeResult::getChangeId) + .collect(Collectors.toList()); + } + + private static String banner(String headline) { + return LINE + NEWLINE + " " + headline + NEWLINE + LINE; + } +} diff --git a/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java b/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java new file mode 100644 index 000000000..b73bdf514 --- /dev/null +++ b/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.common.core.response.data; + +import io.flamingock.internal.common.core.recovery.RecoveryIssue; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExecutionReportFormatterTest { + + @Test + void summaryOnNullReturnsSafeFallback() { + String out = ExecutionReportFormatter.summary(null); + assertTrue(out.contains("no execution data")); + } + + @Test + void summaryOnSuccess() { + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.SUCCESS) + .totalStages(2) + .completedStages(2) + .failedStages(0) + .totalChanges(3) + .appliedChanges(3) + .skippedChanges(0) + .failedChanges(0) + .totalDurationMs(120) + .build(); + + String out = ExecutionReportFormatter.summary(result); + assertTrue(out.startsWith("Flamingock execution completed:"), out); + assertTrue(out.contains("applied=3"), out); + assertTrue(out.contains("duration=120ms"), out); + assertFalse(out.contains("manual intervention"), out); + } + + @Test + void summaryOnStageFailureListsFailedStageNames() { + StageResult completed = StageResult.builder() + .stageId("ok").stageName("ok-stage").state(StageState.COMPLETED).durationMs(50) + .build(); + StageResult failed = StageResult.builder() + .stageId("bad").stageName("bad-stage") + .state(StageState.failed(new ErrorInfo("Boom", "kaboom", Collections.singletonList("c1"), "bad-stage"))) + .durationMs(70) + .changes(Collections.singletonList(failedChange("c1", "Boom", "kaboom"))) + .build(); + + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(2).completedStages(1).failedStages(1) + .totalChanges(2).appliedChanges(1).failedChanges(1) + .totalDurationMs(120) + .stages(Arrays.asList(completed, failed)) + .build(); + + String out = ExecutionReportFormatter.summary(result); + assertTrue(out.startsWith("Flamingock execution failed: 1 of 2 stage(s) failed [bad-stage]"), out); + assertTrue(out.contains("failed=1"), out); + } + + @Test + void summaryAppendsManualInterventionChangeIds() { + StageResult blocked = StageResult.builder() + .stageId("mi").stageName("mi-stage") + .state(StageState.blockedManualIntervention("mi-stage", + Arrays.asList(new RecoveryIssue("change-a"), new RecoveryIssue("change-b")))) + .build(); + + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(1).failedStages(1) + .totalChanges(2).failedChanges(0) + .totalDurationMs(10) + .stages(Collections.singletonList(blocked)) + .build(); + + String out = ExecutionReportFormatter.summary(result); + assertTrue(out.contains("manual intervention required for change(s): change-a, change-b"), out); + } + + @Test + void reportSuccessHeadlineAndPerStageBlock() { + StageResult done = StageResult.builder() + .stageId("only").stageName("only-stage") + .state(StageState.COMPLETED).durationMs(75) + .changes(Collections.singletonList(appliedChange("c1"))) + .build(); + + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.SUCCESS) + .startTime(Instant.parse("2026-05-15T08:00:00Z")) + .endTime(Instant.parse("2026-05-15T08:00:00.075Z")) + .totalDurationMs(75) + .totalStages(1).completedStages(1).failedStages(0) + .totalChanges(1).appliedChanges(1) + .stages(Collections.singletonList(done)) + .build(); + + String out = ExecutionReportFormatter.report(result); + assertTrue(out.contains("Flamingock execution report — SUCCESS"), out); + assertTrue(out.contains("Stages: 1 total — 1 completed, 0 failed"), out); + assertTrue(out.contains("[COMPLETED]"), out); + assertTrue(out.contains("only-stage"), out); + assertTrue(out.contains("1 applied, 0 skipped, 0 failed"), out); + } + + @Test + void reportFailureIncludesErrorBlockAndFailedChangeIds() { + StageResult failed = StageResult.builder() + .stageId("bad").stageName("bad-stage") + .state(StageState.failed(new ErrorInfo("Boom", "kaboom\non two lines", Collections.singletonList("c1"), "bad-stage"))) + .durationMs(42) + .changes(Collections.singletonList(failedChange("c1", "Boom", "kaboom"))) + .build(); + + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(1).failedStages(1) + .totalChanges(1).failedChanges(1) + .totalDurationMs(42) + .stages(Collections.singletonList(failed)) + .build(); + + String out = ExecutionReportFormatter.report(result); + assertTrue(out.contains("Flamingock execution report — FAILED"), out); + assertTrue(out.contains("[FAILED]"), out); + assertTrue(out.contains("error: Boom"), out); + assertTrue(out.contains("kaboom"), out); + assertTrue(out.contains("on two lines"), out); + assertTrue(out.contains("failed change(s): c1"), out); + } + + @Test + void reportBlockedForMIShowsChangeIdsAndDistinctLabel() { + StageResult blocked = StageResult.builder() + .stageId("mi").stageName("mi-stage") + .state(StageState.blockedManualIntervention("mi-stage", + Arrays.asList(new RecoveryIssue("change-a"), new RecoveryIssue("change-b")))) + .build(); + + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(1).failedStages(1) + .stages(Collections.singletonList(blocked)) + .build(); + + String out = ExecutionReportFormatter.report(result); + assertTrue(out.contains("[BLOCKED — manual intervention required]"), out); + assertTrue(out.contains("change(s) requiring intervention: change-a, change-b"), out); + } + + @Test + void reportOnNullReturnsSafeBanner() { + String out = ExecutionReportFormatter.report(null); + assertNotNull(out); + assertTrue(out.contains("NO DATA"), out); + } + + @Test + void reportToleratesNullStagesAndPartialData() { + ExecuteResponseData result = new ExecuteResponseData(); + result.setStages(null); + // Should not throw. + String out = ExecutionReportFormatter.report(result); + assertNotNull(out); + } + + @Test + void reportToleratesStageWithNullStateAndNullChanges() { + StageResult naked = new StageResult(); + naked.setStageName(null); + naked.setState(null); + naked.setChanges(null); + + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.SUCCESS) + .stages(Collections.singletonList(naked)) + .build(); + + // Should not throw despite null state/name/changes. + String out = ExecutionReportFormatter.report(result); + assertNotNull(out); + assertTrue(out.contains("(unnamed)"), out); + } + + private static ChangeResult appliedChange(String id) { + return ChangeResult.builder().changeId(id).status(ChangeStatus.APPLIED).build(); + } + + private static ChangeResult failedChange(String id, String errorType, String errorMessage) { + return ChangeResult.builder() + .changeId(id) + .status(ChangeStatus.FAILED) + .errorType(errorType) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java index 5dce1754a..b70d28030 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java @@ -147,6 +147,17 @@ public HOLDER setEnabled(boolean enabled) { return getSelf(); } + @Override + public HOLDER setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport) { + coreConfiguration.setEnableDefaultExecutionReport(enableDefaultExecutionReport); + return getSelf(); + } + + @Override + public boolean isEnableDefaultExecutionReport() { + return coreConfiguration.isEnableDefaultExecutionReport(); + } + @Override public HOLDER setServiceIdentifier(String serviceIdentifier) { coreConfiguration.setServiceIdentifier(serviceIdentifier); diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java index 3d888e3dd..08257f822 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java @@ -36,6 +36,8 @@ import io.flamingock.internal.core.event.CompositeEventPublisher; import io.flamingock.internal.core.event.EventPublisher; import io.flamingock.internal.core.event.SimpleEventPublisher; +import io.flamingock.internal.core.event.listener.DefaultPipelineCompletedReportListener; +import io.flamingock.internal.core.event.listener.DefaultPipelineFailedReportListener; import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; import io.flamingock.internal.core.event.model.IPipelineFailedEvent; import io.flamingock.internal.core.event.model.IPipelineIgnoredEvent; @@ -307,9 +309,9 @@ private EventPublisher buildEventPublisher() { SimpleEventPublisher simpleEventPublisher = new SimpleEventPublisher() //pipeline events .addListener(IPipelineStartedEvent.class, getPipelineStartedListener()) - .addListener(IPipelineCompletedEvent.class, getPipelineCompletedListener()) + .addListener(IPipelineCompletedEvent.class, composedPipelineCompletedListener()) .addListener(IPipelineIgnoredEvent.class, getPipelineIgnoredListener()) - .addListener(IPipelineFailedEvent.class, getPipelineFailureListener()) + .addListener(IPipelineFailedEvent.class, composedPipelineFailedListener()) //stage events .addListener(IStageStartedEvent.class, getStageStartedListener()) .addListener(IStageCompletedEvent.class, getStageCompletedListener()) @@ -326,6 +328,29 @@ private EventPublisher buildEventPublisher() { return new CompositeEventPublisher(eventPublishersFromPlugins); } + /** + * Composes the default execution-report listener with the user-supplied listener when + * {@code enableDefaultExecutionReport} is on. Order is deterministic: default fires first, + * user fires after — so user code sees a stable log state when it runs. + */ + private Consumer composedPipelineCompletedListener() { + Consumer userListener = getPipelineCompletedListener(); + if (!coreConfiguration.isEnableDefaultExecutionReport()) { + return userListener; + } + Consumer defaultListener = new DefaultPipelineCompletedReportListener(); + return userListener != null ? defaultListener.andThen(userListener) : defaultListener; + } + + private Consumer composedPipelineFailedListener() { + Consumer userListener = getPipelineFailureListener(); + if (!coreConfiguration.isEnableDefaultExecutionReport()) { + return userListener; + } + Consumer defaultListener = new DefaultPipelineFailedReportListener(); + return userListener != null ? defaultListener.andThen(userListener) : defaultListener; + } + /////////////////////////////////////////////////////////////////////////////////// // CORE diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java index 7cdc18423..85b40ac45 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java @@ -42,6 +42,10 @@ public interface CoreConfigurable { boolean isValidationOnly(); + void setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport); + + boolean isEnableDefaultExecutionReport(); + void setServiceIdentifier(String serviceIdentifier); void setMetadata(Map metadata); diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java index 3e7ca8bed..1818f2030 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java @@ -42,6 +42,15 @@ public class CoreConfiguration implements CoreConfigurable { */ private boolean validationOnly = false; + /** + * If true, Flamingock registers the default execution-report listener that writes the + * canonical multi-line report to SLF4J under the {@code FK-Report} logger on pipeline + * completion or failure. Set to false to opt out of the report entirely; SLF4J-level + * silencing (e.g. {@code }) is the recommended + * fine-grained control. Default true. + */ + private boolean enableDefaultExecutionReport = true; + /** * Service identifier. */ @@ -103,6 +112,16 @@ public void setValidationOnly(boolean validationOnly) { this.validationOnly = validationOnly; } + @Override + public void setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport) { + this.enableDefaultExecutionReport = enableDefaultExecutionReport; + } + + @Override + public boolean isEnableDefaultExecutionReport() { + return enableDefaultExecutionReport; + } + @Override public void setServiceIdentifier(String serviceIdentifier) { this.serviceIdentifier = serviceIdentifier; diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java index 5389bf091..b4ab546b7 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java @@ -32,6 +32,10 @@ public interface CoreConfigurator { HOLDER setEnabled(boolean enabled); + HOLDER setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport); + + boolean isEnableDefaultExecutionReport(); + HOLDER setServiceIdentifier(String serviceIdentifier); HOLDER setMetadata(Map metadata); diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java new file mode 100644 index 000000000..f6e7749a6 --- /dev/null +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.event.listener; + +import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter; +import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; +import io.flamingock.internal.util.log.FlamingockLoggerFactory; +import org.slf4j.Logger; + +import java.util.function.Consumer; + +/** + * Writes the canonical execution report at {@code INFO} on the {@code FK-Report} logger when a + * pipeline completes successfully. Registered by the builder unless + * {@code enableDefaultExecutionReport(false)} is set. + * + *

Defensive: must never throw. A failure inside the formatter is reported as a single + * fallback {@code ERROR} line and swallowed so it cannot mask the run outcome. + */ +public final class DefaultPipelineCompletedReportListener implements Consumer { + + public static final String LOGGER_NAME = "FK-Report"; + + private static final Logger logger = FlamingockLoggerFactory.getLogger(LOGGER_NAME); + + @Override + public void accept(IPipelineCompletedEvent event) { + try { + String report = ExecutionReportFormatter.report(event != null ? event.getResult() : null); + logger.info("{}{}", System.lineSeparator(), report); + } catch (Throwable t) { + logger.error("FK-Report rendering failed: {}", t.toString(), t); + } + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java new file mode 100644 index 000000000..50ed6dc00 --- /dev/null +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.event.listener; + +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter; +import io.flamingock.internal.core.event.model.IPipelineFailedEvent; +import io.flamingock.internal.util.log.FlamingockLoggerFactory; +import org.slf4j.Logger; + +import java.util.function.Consumer; + +/** + * Writes the canonical execution report at {@code ERROR} on the {@code FK-Report} logger when a + * pipeline fails. Registered by the builder unless {@code enableDefaultExecutionReport(false)} + * is set. + * + *

Defensive: must never throw. If the event carries no response data we fall back to logging + * the carried exception only; if the formatter itself fails we emit a single fallback line and + * swallow the throwable. + */ +public final class DefaultPipelineFailedReportListener implements Consumer { + + public static final String LOGGER_NAME = "FK-Report"; + + private static final Logger logger = FlamingockLoggerFactory.getLogger(LOGGER_NAME); + + @Override + public void accept(IPipelineFailedEvent event) { + try { + ExecuteResponseData result = event != null ? event.getResult() : null; + Exception cause = event != null ? event.getException() : null; + if (result == null) { + logger.error("Flamingock execution failed (no execution data available)", cause); + return; + } + logger.error("{}{}", System.lineSeparator(), ExecutionReportFormatter.report(result)); + } catch (Throwable t) { + logger.error("FK-Report rendering failed: {}", t.toString(), t); + } + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java index 2e553b25d..9cd06520b 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java @@ -15,6 +15,10 @@ */ package io.flamingock.internal.core.event.model; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; + public interface IPipelineCompletedEvent extends Event { + ExecuteResponseData getResult(); + } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java index c2b40b530..129f4ac45 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java @@ -15,10 +15,12 @@ */ package io.flamingock.internal.core.event.model; -public interface IPipelineFailedEvent extends Event { +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +public interface IPipelineFailedEvent extends Event { Exception getException(); + ExecuteResponseData getResult(); } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java index 06457111d..ef61fe16a 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java @@ -15,7 +15,9 @@ */ package io.flamingock.internal.core.event.model; +import io.flamingock.internal.common.core.response.data.StageResult; + public interface IStageCompletedEvent extends Event { - Object getResult(); + StageResult getResult(); } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java index e9db7986d..920dbea5a 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java @@ -15,10 +15,12 @@ */ package io.flamingock.internal.core.event.model; -public interface IStageFailedEvent extends Event { +import io.flamingock.internal.common.core.response.data.StageResult; +public interface IStageFailedEvent extends Event { Exception getException(); + StageResult getResult(); } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java index e2438f1a1..f1b57b2c2 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java @@ -15,12 +15,19 @@ */ package io.flamingock.internal.core.event.model.impl; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; public class PipelineCompletedEvent implements IPipelineCompletedEvent { + private final ExecuteResponseData result; - public PipelineCompletedEvent() { + public PipelineCompletedEvent(ExecuteResponseData result) { + this.result = result; } + @Override + public ExecuteResponseData getResult() { + return result; + } } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java index 5dbfe218d..a95a2ca21 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java @@ -15,18 +15,26 @@ */ package io.flamingock.internal.core.event.model.impl; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; import io.flamingock.internal.core.event.model.IPipelineFailedEvent; public class PipelineFailedEvent implements IPipelineFailedEvent { private final Exception throwable; + private final ExecuteResponseData result; - public PipelineFailedEvent(Exception throwable) { + public PipelineFailedEvent(Exception throwable, ExecuteResponseData result) { this.throwable = throwable; + this.result = result; } @Override public Exception getException() { return throwable; } + + @Override + public ExecuteResponseData getResult() { + return result; + } } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java index 2ac3901cb..913fd232f 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java @@ -15,17 +15,19 @@ */ package io.flamingock.internal.core.event.model.impl; +import io.flamingock.internal.common.core.response.data.StageResult; import io.flamingock.internal.core.event.model.IStageCompletedEvent; public class StageCompletedEvent implements IStageCompletedEvent { - private final Object result; + private final StageResult result; - public StageCompletedEvent(Object result) { + public StageCompletedEvent(StageResult result) { this.result = result; } + @Override - public Object getResult() { + public StageResult getResult() { return result; } } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java index 3df76f851..480f59bca 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java @@ -16,18 +16,26 @@ package io.flamingock.internal.core.event.model.impl; +import io.flamingock.internal.common.core.response.data.StageResult; import io.flamingock.internal.core.event.model.IStageFailedEvent; public class StageFailedEvent implements IStageFailedEvent { private final Exception throwable; + private final StageResult result; - public StageFailedEvent(Exception throwable) { + public StageFailedEvent(Exception throwable, StageResult result) { this.throwable = throwable; + this.result = result; } @Override public Exception getException() { return throwable; } + + @Override + public StageResult getResult() { + return result; + } } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java index 5e5e58e50..52380ad1e 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java @@ -160,7 +160,7 @@ private ExecuteResult execute(LoadedPipeline pipeline) { // response reflects the pipeline-wide error carried in pipelineLevelError. result.setStatus(ExecutionStatus.FAILED); logger.debug("Error executing the process. ABORTED OPERATION", pipelineLevelError); - eventPublisher.publish(new PipelineFailedEvent(toException(pipelineLevelError))); + eventPublisher.publish(new PipelineFailedEvent(toException(pipelineLevelError), result)); if (throwPipelineLevelError) { throw ExecuteOperationException.fromExisting(pipelineLevelError, result); } @@ -168,17 +168,12 @@ private ExecuteResult execute(LoadedPipeline pipeline) { } if (hasAnyFailedStage(pipelineRun)) { - logger.info("Flamingock execution finished with stage failures [duration={}ms applied={} skipped={} failed={}]", - result.getTotalDurationMs(), result.getAppliedChanges(), result.getSkippedChanges(), result.getFailedChanges()); StagedExecuteOperationException stagedException = new StagedExecuteOperationException(result); - eventPublisher.publish(new PipelineFailedEvent(stagedException)); + eventPublisher.publish(new PipelineFailedEvent(stagedException, result)); throw stagedException; } - logger.info("Flamingock execution completed [duration={}ms applied={} skipped={}]", - result.getTotalDurationMs(), result.getAppliedChanges(), result.getSkippedChanges()); - - eventPublisher.publish(new PipelineCompletedEvent()); + eventPublisher.publish(new PipelineCompletedEvent(result)); return new ExecuteResult(result); } @@ -193,7 +188,7 @@ private void runStage(String executionId, Lock lock, ExecutableStage executableS pipelineRun.markStageStarted(stageName); eventPublisher.publish(new StageStartedEvent()); pipelineRun.markStageBlockedFromMI(stageName, miException.getConflictingChanges()); - eventPublisher.publish(new StageFailedEvent(miException)); + eventPublisher.publish(new StageFailedEvent(miException, pipelineRun.getStageRun(stageName).getResult())); return; } @@ -201,10 +196,10 @@ private void runStage(String executionId, Lock lock, ExecutableStage executableS startStage(executionId, lock, executableStage, pipelineRun); } catch (StageExecutionException exception) { pipelineRun.markStageFailed(stageName, exception); - eventPublisher.publish(new StageFailedEvent(exception)); + eventPublisher.publish(new StageFailedEvent(exception, pipelineRun.getStageRun(stageName).getResult())); } catch (Throwable generalException) { pipelineRun.markStageFailed(stageName, generalException); - eventPublisher.publish(new StageFailedEvent(toException(generalException))); + eventPublisher.publish(new StageFailedEvent(toException(generalException), pipelineRun.getStageRun(stageName).getResult())); } } @@ -216,7 +211,7 @@ private void startStage(String executionId, Lock lock, ExecutableStage executabl ExecutionContext executionContext = new ExecutionContext(executionId, orphanExecutionContext.getHostname(), orphanExecutionContext.getMetadata()); StageExecutor.Output executionOutput = stageExecutor.executeStage(executableStage, executionContext, lock); pipelineRun.markStageCompleted(executableStage.getName(), executionOutput.getResult()); - eventPublisher.publish(new StageCompletedEvent(executionOutput)); + eventPublisher.publish(new StageCompletedEvent(executionOutput.getResult())); } private static boolean hasAnyFailedStage(PipelineRun pipelineRun) { diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java index f3fc8135d..31c7acbe0 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java @@ -16,6 +16,7 @@ package io.flamingock.internal.core.operation; import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter; /** * Thrown when a pipeline-wide error breaks iteration (e.g. {@code LockException}, planner abort, @@ -31,4 +32,10 @@ public PipelineExecuteOperationException(Throwable cause, ExecuteResponseData re public PipelineExecuteOperationException(String message, Throwable cause, ExecuteResponseData result) { super(message, cause, result); } + + // Rich multi-line report; getMessage() stays as Throwable's default (cause-derived) for log aggregators. + @Override + public String toString() { + return ExecutionReportFormatter.report(getResult()); + } } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java index 2a49f0613..b4d8a02ac 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java @@ -16,39 +16,27 @@ package io.flamingock.internal.core.operation; import io.flamingock.internal.common.core.response.data.ExecuteResponseData; -import io.flamingock.internal.common.core.response.data.StageResult; - -import java.util.stream.Collectors; +import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter; /** * Thrown when one or more stages ended in a failed state ({@code Failed} or * {@code BlockedForMI}) but the pipeline iteration completed. The per-stage error details live * in {@code getResult().getStages()}. * - *

The exception message is a single-line, log-aggregator-friendly summary built from the - * carried {@link ExecuteResponseData}: failed stage count + names, change counts, and run - * duration. Multi-line / per-stage detail rendering is deferred to a future {@code toString()} - * override and a default event listener (see {@code docs/ERROR_REPORTING_PROPOSAL.md}). + *

The {@link #getMessage()} is a single-line, log-aggregator-friendly summary (failed stage + * count + names, change counts, run duration, and the IDs of any change requiring manual + * intervention). The full multi-line per-stage report is available via {@link #toString()} — + * see {@code docs/ERROR_REPORTING_PROPOSAL.md} for why the two intentionally differ. */ public class StagedExecuteOperationException extends ExecuteOperationException { public StagedExecuteOperationException(ExecuteResponseData result) { - super(buildMessage(result), result); + super(ExecutionReportFormatter.summary(result), result); } - private static String buildMessage(ExecuteResponseData result) { - String failedStageNames = result.getStages().stream() - .filter(s -> s.getState().isFailed()) - .map(StageResult::getStageName) - .collect(Collectors.joining(", ")); - return String.format( - "Flamingock execution failed: %d of %d stage(s) failed [%s]; changes applied=%d, failed=%d, skipped=%d; duration=%dms", - result.getFailedStages(), - result.getTotalStages(), - failedStageNames, - result.getAppliedChanges(), - result.getFailedChanges(), - result.getSkippedChanges(), - result.getTotalDurationMs()); + // Rich multi-line report; getMessage() stays one-line for log aggregators. + @Override + public String toString() { + return ExecutionReportFormatter.report(getResult()); } } diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java new file mode 100644 index 000000000..01872ec95 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.configuration.core; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EnableDefaultExecutionReportFlagTest { + + @Test + void defaultIsTrue() { + CoreConfiguration cfg = new CoreConfiguration(); + assertTrue(cfg.isEnableDefaultExecutionReport(), + "Default execution report listener must be enabled by default — opt-out, not opt-in"); + } + + @Test + void setterTogglesValue() { + CoreConfiguration cfg = new CoreConfiguration(); + cfg.setEnableDefaultExecutionReport(false); + assertFalse(cfg.isEnableDefaultExecutionReport()); + cfg.setEnableDefaultExecutionReport(true); + assertTrue(cfg.isEnableDefaultExecutionReport()); + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java new file mode 100644 index 000000000..eeee1e7a3 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.event.listener; + +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.common.core.response.data.ExecutionStatus; +import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class DefaultPipelineCompletedReportListenerTest { + + private final DefaultPipelineCompletedReportListener listener = new DefaultPipelineCompletedReportListener(); + + @Test + void acceptsNormalEventWithoutThrowing() { + ExecuteResponseData data = ExecuteResponseData.builder() + .status(ExecutionStatus.SUCCESS) + .totalStages(1).completedStages(1) + .totalDurationMs(50) + .build(); + assertDoesNotThrow(() -> listener.accept(() -> data)); + } + + @Test + void acceptsNullEventWithoutThrowing() { + assertDoesNotThrow(() -> listener.accept(null)); + } + + @Test + void acceptsEventWithNullResultWithoutThrowing() { + assertDoesNotThrow(() -> listener.accept(() -> null)); + } + + @Test + void swallowsThrowableFromPoisonedEvent() { + IPipelineCompletedEvent poisoned = () -> { throw new IllegalStateException("blew up"); }; + assertDoesNotThrow(() -> listener.accept(poisoned)); + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java new file mode 100644 index 000000000..006743df2 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.event.listener; + +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.common.core.response.data.ExecutionStatus; +import io.flamingock.internal.core.event.model.IPipelineFailedEvent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class DefaultPipelineFailedReportListenerTest { + + private final DefaultPipelineFailedReportListener listener = new DefaultPipelineFailedReportListener(); + + @Test + void acceptsNormalEventWithoutThrowing() { + ExecuteResponseData data = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(1).failedStages(1) + .totalDurationMs(50) + .build(); + Exception cause = new RuntimeException("boom"); + assertDoesNotThrow(() -> listener.accept(event(cause, data))); + } + + @Test + void acceptsNullEventWithoutThrowing() { + assertDoesNotThrow(() -> listener.accept(null)); + } + + @Test + void acceptsEventWithNullResultWithoutThrowing() { + assertDoesNotThrow(() -> listener.accept(event(new RuntimeException("boom"), null))); + } + + @Test + void swallowsThrowableFromPoisonedEvent() { + IPipelineFailedEvent poisoned = new IPipelineFailedEvent() { + @Override + public Exception getException() { + throw new IllegalStateException("getException blew up"); + } + + @Override + public ExecuteResponseData getResult() { + throw new IllegalStateException("getResult blew up"); + } + }; + // Contract: defensive — must never propagate. + assertDoesNotThrow(() -> listener.accept(poisoned)); + } + + private static IPipelineFailedEvent event(Exception cause, ExecuteResponseData result) { + return new IPipelineFailedEvent() { + @Override + public Exception getException() { + return cause; + } + + @Override + public ExecuteResponseData getResult() { + return result; + } + }; + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java new file mode 100644 index 000000000..dc87dac36 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.operation; + +import io.flamingock.internal.common.core.recovery.RecoveryIssue; +import io.flamingock.internal.common.core.response.data.ErrorInfo; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.common.core.response.data.ExecutionStatus; +import io.flamingock.internal.common.core.response.data.StageResult; +import io.flamingock.internal.common.core.response.data.StageState; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StagedExecuteOperationExceptionTest { + + @Test + void getMessageIsSingleLineAndContainsFailedStageNames() { + ExecuteResponseData result = failureResult(); + StagedExecuteOperationException ex = new StagedExecuteOperationException(result); + + String msg = ex.getMessage(); + assertFalse(msg.contains(System.lineSeparator()), "getMessage must remain single-line: " + msg); + assertTrue(msg.startsWith("Flamingock execution failed:"), msg); + assertTrue(msg.contains("bad-stage"), msg); + assertTrue(msg.contains("failed=1"), msg); + } + + @Test + void getMessageAppendsManualInterventionChangeIds() { + StageResult blocked = StageResult.builder() + .stageId("mi").stageName("mi-stage") + .state(StageState.blockedManualIntervention("mi-stage", + Arrays.asList(new RecoveryIssue("change-a"), new RecoveryIssue("change-b")))) + .build(); + ExecuteResponseData result = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(1).failedStages(1) + .stages(Collections.singletonList(blocked)) + .build(); + + StagedExecuteOperationException ex = new StagedExecuteOperationException(result); + assertTrue(ex.getMessage().contains("manual intervention required for change(s): change-a, change-b"), + ex.getMessage()); + } + + @Test + void toStringReturnsMultiLineReport() { + ExecuteResponseData result = failureResult(); + StagedExecuteOperationException ex = new StagedExecuteOperationException(result); + + String rendered = ex.toString(); + assertTrue(rendered.contains(System.lineSeparator()), + "toString must be multi-line: " + rendered); + assertTrue(rendered.contains("Flamingock execution report"), rendered); + assertTrue(rendered.contains("[FAILED]"), rendered); + } + + private static ExecuteResponseData failureResult() { + StageResult failed = StageResult.builder() + .stageId("bad").stageName("bad-stage") + .state(StageState.failed(new ErrorInfo("Boom", "kaboom", Collections.singletonList("c1"), "bad-stage"))) + .durationMs(70) + .build(); + return ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED) + .totalStages(1).failedStages(1) + .totalChanges(1).failedChanges(1) + .totalDurationMs(120) + .stages(Collections.singletonList(failed)) + .build(); + } +} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java index d278c5a01..d945da23f 100644 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java @@ -47,13 +47,17 @@ public class InMemoryFlamingockBuilder extends CommunityChangeRunnerBuilder { public static InMemoryFlamingockBuilder create() { - return new InMemoryFlamingockBuilder( + InMemoryFlamingockBuilder builder = new InMemoryFlamingockBuilder( new CoreConfiguration(), new CommunityConfiguration(), new SimpleContext(), new DefaultPluginManager(), InMemoryAuditStore.create() ); + // Keep unit-test logs clean: opt out of the default FK-Report listener by default. + // Tests verifying report content can re-enable via setEnableDefaultExecutionReport(true). + builder.setEnableDefaultExecutionReport(false); + return builder; } private InMemoryFlamingockBuilder(CoreConfiguration coreConfiguration, diff --git a/docs/ERROR_REPORTING_PROPOSAL.md b/docs/ERROR_REPORTING_PROPOSAL.md index cbacf6353..f01ce6379 100644 --- a/docs/ERROR_REPORTING_PROPOSAL.md +++ b/docs/ERROR_REPORTING_PROPOSAL.md @@ -1,11 +1,11 @@ # Error reporting proposal — rich execution report via event listener -**Status:** partially implemented. -- **Step 1 (single-line `getMessage()` summary) — DONE** on `StagedExecuteOperationException`. The message now reads e.g. `"Flamingock execution failed: 1 of 3 stage(s) failed [flamingock-system-stage]; changes applied=3, failed=1, skipped=0; duration=312ms"`. The message-building helper is inlined as a `private static String buildMessage(ExecuteResponseData)` inside the exception class — natural seed to extract into a reusable `ExecutionReportFormatter.summary(...)` when step 2 lands. -- **Step 2 (`toString()` override with rich multi-line report) — DEFERRED.** -- **Step 3 (default event listener writing via SLF4J + builder opt-out) — DEFERRED.** +**Status:** implemented (2026-05-24). +- **Step 1 (single-line `getMessage()` summary) — DONE.** `StagedExecuteOperationException.getMessage()` delegates to `ExecutionReportFormatter.summary(...)`. MI change IDs are appended when any stage is blocked. +- **Step 2 (`toString()` override with rich multi-line report) — DONE.** Both `StagedExecuteOperationException` and `PipelineExecuteOperationException` override `toString()` to return `ExecutionReportFormatter.report(...)`. +- **Step 3 (default event listener writing via SLF4J + builder opt-out) — DONE.** Two separate listeners — `DefaultPipelineCompletedReportListener` (INFO) and `DefaultPipelineFailedReportListener` (ERROR) — both writing under the `FK-Report` logger. Composed with any user listener via `Consumer.andThen` when `enableDefaultExecutionReport(true)` (default). Inline summary `logger.info(...)` calls in `AbstractPipelineTraverseOperation` removed in favor of the listener. -**Related code:** `core/flamingock-core/.../operation/AbstractPipelineTraverseOperation.java`, `ExecuteOperationException` / `StagedExecuteOperationException` / `PipelineExecuteOperationException`, `core/flamingock-core-commons/.../response/data/ExecuteResponseData.java`, `core/flamingock-core/.../event/`. +**Related code:** `core/flamingock-core/.../operation/AbstractPipelineTraverseOperation.java`, `ExecuteOperationException` / `StagedExecuteOperationException` / `PipelineExecuteOperationException`, `core/flamingock-core-commons/.../response/data/{ExecuteResponseData,ExecutionReportFormatter}.java`, `core/flamingock-core/.../event/listener/`, `core/flamingock-core/.../builder/AbstractChangeRunnerBuilder.java`, `core/flamingock-core/.../configuration/core/CoreConfiguration.java`. Spring Boot wrappers under `platform-plugins/flamingock-springboot-integration/.../event/` pass `getResult()` through (and the four `SpringStage*Event` classes were re-typed to implement their correct `IStage*Event` interfaces — they were previously cross-wired to `IPipeline*Event`). ## Context diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java index 9466dc3c6..eae960204 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java @@ -100,6 +100,16 @@ public void setValidationOnly(boolean validationOnly) { coreConfiguration.setValidationOnly(validationOnly); } + @Override + public void setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport) { + coreConfiguration.setEnableDefaultExecutionReport(enableDefaultExecutionReport); + } + + @Override + public boolean isEnableDefaultExecutionReport() { + return coreConfiguration.isEnableDefaultExecutionReport(); + } + @Override public void setServiceIdentifier(String serviceIdentifier) { coreConfiguration.setServiceIdentifier(serviceIdentifier); diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java index 44403c47d..824d36a4e 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java @@ -16,6 +16,7 @@ package io.flamingock.springboot.event; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; import org.springframework.context.ApplicationEvent; @@ -34,6 +35,11 @@ public SpringPipelineCompletedEvent(Object source, IPipelineCompletedEvent event this.event = event; } + @Override + public ExecuteResponseData getResult() { + return event.getResult(); + } + @Override public String toString() { return "SpringPipelineCompletedEvent{" + diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java index a0545bb1f..e928ac50b 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java @@ -15,6 +15,7 @@ */ package io.flamingock.springboot.event; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; import io.flamingock.internal.core.event.model.IPipelineFailedEvent; import org.springframework.context.ApplicationEvent; @@ -37,7 +38,10 @@ public Exception getException() { return event.getException(); } - + @Override + public ExecuteResponseData getResult() { + return event.getResult(); + } @Override public String toString() { diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java index e2ab02e10..ff6432d61 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java @@ -16,11 +16,11 @@ package io.flamingock.springboot.event; -import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; +import io.flamingock.internal.common.core.response.data.StageResult; import io.flamingock.internal.core.event.model.IStageCompletedEvent; import org.springframework.context.ApplicationEvent; -public class SpringStageCompletedEvent extends ApplicationEvent implements IPipelineCompletedEvent { +public class SpringStageCompletedEvent extends ApplicationEvent implements IStageCompletedEvent { private final IStageCompletedEvent event; @@ -35,10 +35,14 @@ public SpringStageCompletedEvent(Object source, IStageCompletedEvent event) { this.event = event; } + @Override + public StageResult getResult() { + return event.getResult(); + } @Override public String toString() { - return "SpringPipelineCompletedEvent{" + + return "SpringStageCompletedEvent{" + "event=" + event + ", source=" + source + '}'; diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java index 266c51a01..5d1489092 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java @@ -15,11 +15,11 @@ */ package io.flamingock.springboot.event; -import io.flamingock.internal.core.event.model.IPipelineFailedEvent; +import io.flamingock.internal.common.core.response.data.StageResult; import io.flamingock.internal.core.event.model.IStageFailedEvent; import org.springframework.context.ApplicationEvent; -public class SpringStageFailedEvent extends ApplicationEvent implements IPipelineFailedEvent { +public class SpringStageFailedEvent extends ApplicationEvent implements IStageFailedEvent { private final IStageFailedEvent event; /** @@ -38,11 +38,14 @@ public Exception getException() { return event.getException(); } - + @Override + public StageResult getResult() { + return event.getResult(); + } @Override public String toString() { - return "SpringPipelineFailedEvent{" + + return "SpringStageFailedEvent{" + "event=" + event + ", source=" + source + '}'; diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java index 2a3b163a4..f7dec48c5 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java @@ -16,11 +16,10 @@ package io.flamingock.springboot.event; -import io.flamingock.internal.core.event.model.IPipelineIgnoredEvent; import io.flamingock.internal.core.event.model.IStageIgnoredEvent; import org.springframework.context.ApplicationEvent; -public class SpringStageIgnoredEvent extends ApplicationEvent implements IPipelineIgnoredEvent { +public class SpringStageIgnoredEvent extends ApplicationEvent implements IStageIgnoredEvent { private final IStageIgnoredEvent event; @@ -38,7 +37,7 @@ public SpringStageIgnoredEvent(Object source, IStageIgnoredEvent event) { @Override public String toString() { - return "SpringPipelineIgnoredEvent{" + + return "SpringStageIgnoredEvent{" + "event=" + event + ", source=" + source + '}'; diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java index 7826cc4ab..7c60e6d7a 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java @@ -15,11 +15,10 @@ */ package io.flamingock.springboot.event; -import io.flamingock.internal.core.event.model.IPipelineStartedEvent; import io.flamingock.internal.core.event.model.IStageStartedEvent; import org.springframework.context.ApplicationEvent; -public class SpringStageStartedEvent extends ApplicationEvent implements IPipelineStartedEvent { +public class SpringStageStartedEvent extends ApplicationEvent implements IStageStartedEvent { private final IStageStartedEvent event; @@ -37,7 +36,7 @@ public SpringStageStartedEvent(Object source, IStageStartedEvent event) { @Override public String toString() { - return "SpringPipelineStartedEvent{" + + return "SpringStageStartedEvent{" + "event=" + event + ", source=" + source + '}'; diff --git a/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java new file mode 100644 index 000000000..b229d4d95 --- /dev/null +++ b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.springboot.event; + +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.common.core.response.data.ExecutionStatus; +import io.flamingock.internal.common.core.response.data.StageResult; +import io.flamingock.internal.common.core.response.data.StageState; +import io.flamingock.internal.core.event.model.IPipelineCompletedEvent; +import io.flamingock.internal.core.event.model.IPipelineFailedEvent; +import io.flamingock.internal.core.event.model.IStageCompletedEvent; +import io.flamingock.internal.core.event.model.IStageFailedEvent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Verifies that the Spring {@code ApplicationEvent} wrappers expose the typed + * {@code ExecuteResponseData} / {@code StageResult} payloads from the wrapped core event, so + * Spring user code can render the canonical execution report without downcasting. + */ +class SpringEventPayloadPassthroughTest { + + private final Object source = new Object(); + + @Test + void pipelineCompletedWrapperExposesResult() { + ExecuteResponseData data = ExecuteResponseData.builder() + .status(ExecutionStatus.SUCCESS).build(); + IPipelineCompletedEvent core = () -> data; + SpringPipelineCompletedEvent wrapper = new SpringPipelineCompletedEvent(source, core); + assertSame(data, wrapper.getResult()); + } + + @Test + void pipelineFailedWrapperExposesResultAndException() { + ExecuteResponseData data = ExecuteResponseData.builder() + .status(ExecutionStatus.FAILED).build(); + Exception cause = new RuntimeException("boom"); + IPipelineFailedEvent core = new IPipelineFailedEvent() { + @Override public Exception getException() { return cause; } + @Override public ExecuteResponseData getResult() { return data; } + }; + SpringPipelineFailedEvent wrapper = new SpringPipelineFailedEvent(source, core); + assertSame(data, wrapper.getResult()); + assertSame(cause, wrapper.getException()); + } + + @Test + void stageCompletedWrapperExposesResult() { + StageResult stage = StageResult.builder() + .stageId("s").stageName("s").state(StageState.COMPLETED).build(); + IStageCompletedEvent core = () -> stage; + SpringStageCompletedEvent wrapper = new SpringStageCompletedEvent(source, core); + assertSame(stage, wrapper.getResult()); + } + + @Test + void stageFailedWrapperExposesResultAndException() { + StageResult stage = StageResult.builder() + .stageId("s").stageName("s") + .state(StageState.failed(null)).build(); + Exception cause = new RuntimeException("boom"); + IStageFailedEvent core = new IStageFailedEvent() { + @Override public Exception getException() { return cause; } + @Override public StageResult getResult() { return stage; } + }; + SpringStageFailedEvent wrapper = new SpringStageFailedEvent(source, core); + assertSame(stage, wrapper.getResult()); + assertSame(cause, wrapper.getException()); + } +} diff --git a/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java b/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java index b807bf620..6b7ba85de 100644 --- a/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java +++ b/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java @@ -70,12 +70,16 @@ public interface TestKit { default TestFlamingockBuilder createBuilderWithAuditStore(CommunityAuditStore auditStore) { - return new TestFlamingockBuilder( + TestFlamingockBuilder builder = new TestFlamingockBuilder( new CoreConfiguration(), new CommunityConfiguration(), new SimpleContext(), new DefaultPluginManager(), auditStore ); + // Keep CI logs clean: opt out of the default FK-Report listener by default. + // Tests verifying report content can re-enable via setEnableDefaultExecutionReport(true). + builder.setEnableDefaultExecutionReport(false); + return builder; } }