DatabaseAuditReportListener.java

package io.github.databaseaudits.spring.boot.report;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.TestPlan;

import io.github.databaseaudits.spring.boot.assertion.DatabaseAuditFailure;

/**
 * A JUnit Platform {@link TestExecutionListener} that collects every
 * {@link DatabaseAuditFailure} a run produces and, when the run finishes, writes
 * a single consolidated report — so findings scattered across per-audit test
 * failures land in one file instead of the build console.
 *
 * <p>
 * It is auto-registered through
 * {@code META-INF/services/org.junit.platform.launcher.TestExecutionListener},
 * so it loads in every test run the integration jar is on the classpath for.
 * That makes staying <em>inert unless there are findings</em> essential: when no
 * {@link DatabaseAuditFailure} is collected it writes nothing, leaving unrelated
 * consumer test runs untouched. The collector is thread-safe, so audits may run
 * in parallel.
 *
 * <p>
 * Output is controlled by the {@link ReportSettings} configuration parameters,
 * read from the run's {@code junit-platform.properties} (with system-property
 * fallback).
 */
public class DatabaseAuditReportListener implements TestExecutionListener {
    private static final System.Logger LOGGER =
            System.getLogger(DatabaseAuditReportListener.class.getName());

    private final List<DatabaseAuditFailure> failures =
            new CopyOnWriteArrayList<>();

    /**
     * Clears any failures collected from a previous run, so this listener —
     * loaded once via {@code ServiceLoader} and reused across runs in the same
     * JVM — starts each run with a clean slate.
     *
     * @param testPlan
     *                     the plan about to execute; unused.
     */
    @Override
    public void testPlanExecutionStarted(final TestPlan testPlan) {
        failures.clear();
    }

    /**
     * Collects the test's failure if it is a {@link DatabaseAuditFailure}; any
     * other outcome — success, a plain failure, or a skip — is ignored.
     *
     * @param testIdentifier
     *                                the finished test; unused.
     * @param testExecutionResult
     *                                the test's outcome.
     */
    @Override
    public void executionFinished(final TestIdentifier testIdentifier,
            final TestExecutionResult testExecutionResult) {
        testExecutionResult.getThrowable()
                .filter(DatabaseAuditFailure.class::isInstance)
                .map(DatabaseAuditFailure.class::cast).ifPresent(failures::add);
    }

    /**
     * Writes the consolidated report once the run finishes, unless no
     * {@link DatabaseAuditFailure} was collected or the report is disabled.
     *
     * @param testPlan
     *                     the finished plan, whose configuration parameters
     *                     resolve the {@link ReportSettings}.
     */
    @Override
    public void testPlanExecutionFinished(final TestPlan testPlan) {
        if (failures.isEmpty()) {
            return;
        }
        final ReportSettings settings =
                ReportSettings.from(testPlan.getConfigurationParameters());
        if (!settings.enabled()) {
            return;
        }
        writeReport(settings, List.copyOf(failures));
    }

    private void writeReport(final ReportSettings settings,
            final List<DatabaseAuditFailure> collected) {
        final String report = DatabaseAuditReport.render(collected,
                settings.format(), settings.fixFormat(), settings.placement(),
                new FixRenderer());
        final Path outputFile = settings.outputFile();
        try {
            final Path parent = outputFile.getParent();
            if (parent != null) {
                Files.createDirectories(parent);
            }
            Files.writeString(outputFile, report);
            LOGGER.log(System.Logger.Level.INFO,
                    () -> "Wrote database audit report to "
                            + outputFile.toAbsolutePath());
        } catch (final IOException e) {
            LOGGER.log(System.Logger.Level.WARNING,
                    "Failed to write database audit report to "
                            + outputFile.toAbsolutePath(),
                    e);
        }
    }
}