From 20e4fbb1d5a1083265c9dbc4c7b7962c55135402 Mon Sep 17 00:00:00 2001 From: NkToGo Date: Wed, 12 Aug 2026 23:59:41 +0200 Subject: [PATCH 1/5] Milestone 6 slice 1: add reporting snapshot boundary --- .../ingestion/ValidationRunReportAccess.java | 8 + .../ingestion/ValidationRunService.java | 8 +- .../reporting/ValidationReport.java | 15 ++ .../reporting/ValidationReportService.java | 37 +++++ .../ValidationIssueReportAccess.java | 9 ++ .../validation/ValidationIssueService.java | 8 +- .../ingestion/ValidationRunServiceTests.java | 44 ++++++ .../ValidationReportServiceTests.java | 148 ++++++++++++++++++ .../ValidationIssueServiceTests.java | 18 +++ 9 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunReportAccess.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReport.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportService.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueReportAccess.java create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportServiceTests.java diff --git a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunReportAccess.java b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunReportAccess.java new file mode 100644 index 0000000..b17a569 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunReportAccess.java @@ -0,0 +1,8 @@ +package io.github.nktogo.dataquality.ingestion; + +import java.util.UUID; + +public interface ValidationRunReportAccess { + + ValidationRunResponse getValidationRunForReport(UUID runId); +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java index 1aa85c6..b57a1d9 100644 --- a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java +++ b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java @@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional; @Service -class ValidationRunService implements ValidationRunAccess { +class ValidationRunService implements ValidationRunAccess, ValidationRunReportAccess { private static final Logger LOGGER = LoggerFactory.getLogger(ValidationRunService.class); @@ -52,6 +52,12 @@ List getAll() { @Transactional(readOnly = true) ValidationRunResponse getById(UUID runId) { + return getValidationRunForReport(runId); + } + + @Override + @Transactional(readOnly = true) + public ValidationRunResponse getValidationRunForReport(UUID runId) { return toResponse(requireExisting(runId)); } diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReport.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReport.java new file mode 100644 index 0000000..df952f3 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReport.java @@ -0,0 +1,15 @@ +package io.github.nktogo.dataquality.reporting; + +import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; +import io.github.nktogo.dataquality.validation.ValidationIssueResponse; +import java.util.List; +import java.util.Objects; + +public record ValidationReport( + ValidationRunResponse validationRun, List issues) { + + public ValidationReport { + Objects.requireNonNull(validationRun, "validationRun must not be null"); + issues = List.copyOf(Objects.requireNonNull(issues, "issues must not be null")); + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportService.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportService.java new file mode 100644 index 0000000..6a0bba8 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportService.java @@ -0,0 +1,37 @@ +package io.github.nktogo.dataquality.reporting; + +import io.github.nktogo.dataquality.ingestion.ValidationRunReportAccess; +import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; +import io.github.nktogo.dataquality.validation.ValidationIssueReportAccess; +import io.github.nktogo.dataquality.validation.ValidationIssueResponse; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +@Service +class ValidationReportService { + + private final ValidationRunReportAccess validationRunReportAccess; + private final ValidationIssueReportAccess validationIssueReportAccess; + + ValidationReportService( + ValidationRunReportAccess validationRunReportAccess, + ValidationIssueReportAccess validationIssueReportAccess) { + this.validationRunReportAccess = validationRunReportAccess; + this.validationIssueReportAccess = validationIssueReportAccess; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + ValidationReport getReport(UUID runId) { + Objects.requireNonNull(runId, "runId must not be null"); + ValidationRunResponse validationRun = + validationRunReportAccess.getValidationRunForReport(runId); + List issues = + validationIssueReportAccess.getValidationIssuesForReport(runId); + + return new ValidationReport(validationRun, issues); + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueReportAccess.java b/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueReportAccess.java new file mode 100644 index 0000000..71ee284 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueReportAccess.java @@ -0,0 +1,9 @@ +package io.github.nktogo.dataquality.validation; + +import java.util.List; +import java.util.UUID; + +public interface ValidationIssueReportAccess { + + List getValidationIssuesForReport(UUID runId); +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueService.java b/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueService.java index 8739385..ea8d470 100644 --- a/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueService.java +++ b/backend/src/main/java/io/github/nktogo/dataquality/validation/ValidationIssueService.java @@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional; @Service -class ValidationIssueService { +class ValidationIssueService implements ValidationIssueReportAccess { private final ValidationIssueRepository validationIssueRepository; private final ValidationRunAccess validationRunAccess; @@ -34,6 +34,12 @@ void persistAll(UUID runId, List drafts) { @Transactional(readOnly = true) List getAll(UUID runId) { + return getValidationIssuesForReport(runId); + } + + @Override + @Transactional(readOnly = true) + public List getValidationIssuesForReport(UUID runId) { Objects.requireNonNull(runId, "runId must not be null"); validationRunAccess.requireValidationRun(runId); diff --git a/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java b/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java index e5e8af1..3367de6 100644 --- a/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java +++ b/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import java.time.Instant; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.InOrder; @@ -45,6 +46,49 @@ void createsPendingRunThenProcessesIt() { verifyNoInteractions(recoveryService); } + @Test + void mapsAnExistingRunThroughTheReportingReadBoundary() { + UUID runId = UUID.randomUUID(); + UUID datasetId = UUID.randomUUID(); + UUID sourceFileId = UUID.randomUUID(); + UUID profileId = UUID.randomUUID(); + ValidationRun validationRun = mock(ValidationRun.class); + when(validationRunRepository.findById(runId)).thenReturn(Optional.of(validationRun)); + when(validationRun.getId()).thenReturn(runId); + when(validationRun.getDatasetId()).thenReturn(datasetId); + when(validationRun.getSourceFileId()).thenReturn(sourceFileId); + when(validationRun.getProfileId()).thenReturn(profileId); + when(validationRun.getStatus()).thenReturn(ValidationRunStatus.PENDING); + + ValidationRunResponse response = service.getValidationRunForReport(runId); + + assertThat(response) + .isEqualTo( + new ValidationRunResponse( + runId, + datasetId, + sourceFileId, + profileId, + ValidationRunStatus.PENDING, + 0, + 0, + 0, + 0, + null, + null, + null)); + } + + @Test + void preservesNotFoundBehaviorAtTheReportingReadBoundary() { + UUID runId = UUID.randomUUID(); + when(validationRunRepository.findById(runId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getValidationRunForReport(runId)) + .isInstanceOf(ValidationRunNotFoundException.class) + .hasMessage("Validation Run '" + runId + "' was not found."); + } + @Test void recoversOnlyValidationProcessingFailuresAfterProcessingTransactionReturns() { UUID fileId = UUID.randomUUID(); diff --git a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportServiceTests.java b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportServiceTests.java new file mode 100644 index 0000000..086530e --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportServiceTests.java @@ -0,0 +1,148 @@ +package io.github.nktogo.dataquality.reporting; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.github.nktogo.dataquality.dataset.ValidationRuleSeverity; +import io.github.nktogo.dataquality.dataset.ValidationRuleType; +import io.github.nktogo.dataquality.ingestion.ValidationRunReportAccess; +import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; +import io.github.nktogo.dataquality.ingestion.ValidationRunStatus; +import io.github.nktogo.dataquality.validation.ValidationIssueReportAccess; +import io.github.nktogo.dataquality.validation.ValidationIssueResponse; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InOrder; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +class ValidationReportServiceTests { + + private static final Instant STARTED_AT = Instant.parse("2026-08-11T12:00:00.123456Z"); + + private final ValidationRunReportAccess validationRunReportAccess = + mock(ValidationRunReportAccess.class); + private final ValidationIssueReportAccess validationIssueReportAccess = + mock(ValidationIssueReportAccess.class); + private final ValidationReportService service = + new ValidationReportService(validationRunReportAccess, validationIssueReportAccess); + + @Test + void readsThePersistedRunBeforeItsOrderedIssuesAndReturnsAnImmutableSnapshot() { + UUID runId = UUID.randomUUID(); + ValidationRunResponse run = run(runId, ValidationRunStatus.COMPLETED); + List mutableIssues = + new ArrayList<>( + List.of(issue(runId, 2, "first", ""), issue(runId, 4, "second", "unchanged"))); + when(validationRunReportAccess.getValidationRunForReport(runId)).thenReturn(run); + when(validationIssueReportAccess.getValidationIssuesForReport(runId)).thenReturn(mutableIssues); + + ValidationReport report = service.getReport(runId); + mutableIssues.clear(); + + InOrder calls = inOrder(validationRunReportAccess, validationIssueReportAccess); + calls.verify(validationRunReportAccess).getValidationRunForReport(runId); + calls.verify(validationIssueReportAccess).getValidationIssuesForReport(runId); + assertThat(report.validationRun()).isSameAs(run); + assertThat(report.issues()) + .extracting(ValidationIssueResponse::fieldName) + .containsExactly("first", "second"); + assertThatThrownBy(() -> report.issues().add(report.issues().getFirst())) + .isInstanceOf(UnsupportedOperationException.class); + } + + @ParameterizedTest + @EnumSource(ValidationRunStatus.class) + void preservesEveryPersistedValidationRunStatus(ValidationRunStatus status) { + UUID runId = UUID.randomUUID(); + ValidationRunResponse run = run(runId, status); + when(validationRunReportAccess.getValidationRunForReport(runId)).thenReturn(run); + when(validationIssueReportAccess.getValidationIssuesForReport(runId)).thenReturn(List.of()); + + ValidationReport report = service.getReport(runId); + + assertThat(report.validationRun()).isSameAs(run); + assertThat(report.issues()).isEmpty(); + } + + @Test + void stopsBeforeReadingIssuesWhenTheRunDoesNotExist() { + UUID runId = UUID.randomUUID(); + IllegalStateException notFound = new IllegalStateException("run not found"); + when(validationRunReportAccess.getValidationRunForReport(runId)).thenThrow(notFound); + + assertThatThrownBy(() -> service.getReport(runId)).isSameAs(notFound); + + verifyNoInteractions(validationIssueReportAccess); + } + + @Test + void rejectsNullBeforeCrossingAReadBoundary() { + assertThatThrownBy(() -> service.getReport(null)).isInstanceOf(NullPointerException.class); + + verifyNoInteractions(validationRunReportAccess, validationIssueReportAccess); + } + + @Test + void declaresAReadOnlyRepeatableReadSnapshot() throws NoSuchMethodException { + Method method = ValidationReportService.class.getDeclaredMethod("getReport", UUID.class); + + Transactional transactional = method.getAnnotation(Transactional.class); + + assertThat(transactional).isNotNull(); + assertThat(transactional.readOnly()).isTrue(); + assertThat(transactional.isolation()).isEqualTo(Isolation.REPEATABLE_READ); + } + + @Test + void reportModelRejectsNullComponents() { + ValidationRunResponse run = run(UUID.randomUUID(), ValidationRunStatus.PENDING); + + assertThatThrownBy(() -> new ValidationReport(null, List.of())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new ValidationReport(run, null)) + .isInstanceOf(NullPointerException.class); + } + + private ValidationRunResponse run(UUID runId, ValidationRunStatus status) { + boolean started = status != ValidationRunStatus.PENDING; + boolean finished = + status == ValidationRunStatus.COMPLETED || status == ValidationRunStatus.FAILED; + return new ValidationRunResponse( + runId, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + status, + 4, + status == ValidationRunStatus.COMPLETED ? 3 : 0, + status == ValidationRunStatus.COMPLETED ? 1 : 0, + status == ValidationRunStatus.COMPLETED ? 2 : 0, + started ? STARTED_AT : null, + finished ? STARTED_AT.plusSeconds(1) : null, + status == ValidationRunStatus.FAILED ? "Validation failed." : null); + } + + private ValidationIssueResponse issue( + UUID runId, long rowNumber, String fieldName, String observedValue) { + return new ValidationIssueResponse( + UUID.randomUUID(), + runId, + rowNumber, + fieldName, + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "A persisted message.", + observedValue); + } +} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/validation/ValidationIssueServiceTests.java b/backend/src/test/java/io/github/nktogo/dataquality/validation/ValidationIssueServiceTests.java index 31027b9..728766c 100644 --- a/backend/src/test/java/io/github/nktogo/dataquality/validation/ValidationIssueServiceTests.java +++ b/backend/src/test/java/io/github/nktogo/dataquality/validation/ValidationIssueServiceTests.java @@ -214,6 +214,24 @@ void requiresTheRunBeforeReadingAndReturnsAnImmutableMappedList() { .isInstanceOf(UnsupportedOperationException.class); } + @Test + void reportingReadBoundaryPreservesTheExistingOrderedEmptyCollectionBehavior() { + UUID runId = UUID.randomUUID(); + when(validationIssueRepository.findAllByRunIdOrderByRowNumberAscFieldNameAscRuleTypeAscIdAsc( + runId)) + .thenReturn(List.of()); + + List responses = + validationIssueService.getValidationIssuesForReport(runId); + + InOrder calls = inOrder(validationRunAccess, validationIssueRepository); + calls.verify(validationRunAccess).requireValidationRun(runId); + calls + .verify(validationIssueRepository) + .findAllByRunIdOrderByRowNumberAscFieldNameAscRuleTypeAscIdAsc(runId); + assertThat(responses).isEmpty(); + } + private ValidationIssueDraft draft( long rowNumber, String fieldName, From 1176a5bc1c04030caa0d7fef949504f4d95a07b1 Mon Sep 17 00:00:00 2001 From: NkToGo Date: Thu, 13 Aug 2026 01:06:38 +0200 Subject: [PATCH 2/5] Milestone 6 silce 2: add JSON and CSV report export --- .../InvalidReportFormatException.java | 8 + .../dataquality/reporting/ReportFormat.java | 30 + .../reporting/ValidationReportController.java | 59 ++ .../reporting/ValidationReportCsvWriter.java | 100 +++ .../ValidationReportExceptionHandler.java | 23 + .../reporting/ReportFormatTests.java | 42 ++ .../ValidationReportCsvWriterTests.java | 268 +++++++ .../ValidationReportIntegrationTests.java | 700 ++++++++++++++++++ 8 files changed, 1230 insertions(+) create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/InvalidReportFormatException.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/ReportFormat.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriter.java create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportExceptionHandler.java create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/reporting/ReportFormatTests.java create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriterTests.java create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportIntegrationTests.java diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/InvalidReportFormatException.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/InvalidReportFormatException.java new file mode 100644 index 0000000..bd0a846 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/InvalidReportFormatException.java @@ -0,0 +1,8 @@ +package io.github.nktogo.dataquality.reporting; + +final class InvalidReportFormatException extends RuntimeException { + + InvalidReportFormatException() { + super("Query parameter 'format' must be exactly one of: json, csv."); + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ReportFormat.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ReportFormat.java new file mode 100644 index 0000000..defbcbc --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ReportFormat.java @@ -0,0 +1,30 @@ +package io.github.nktogo.dataquality.reporting; + +import java.util.List; + +enum ReportFormat { + JSON("json"), + CSV("csv"); + + private final String queryValue; + + ReportFormat(String queryValue) { + this.queryValue = queryValue; + } + + String queryValue() { + return queryValue; + } + + static ReportFormat parse(List values) { + if (values == null || values.size() != 1) { + throw new InvalidReportFormatException(); + } + + return switch (values.getFirst()) { + case "json" -> JSON; + case "csv" -> CSV; + default -> throw new InvalidReportFormatException(); + }; + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java new file mode 100644 index 0000000..4706e14 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java @@ -0,0 +1,59 @@ +package io.github.nktogo.dataquality.reporting; + +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ValidationReportController { + + private static final MediaType CSV_MEDIA_TYPE = + new MediaType("text", "csv", StandardCharsets.UTF_8); + + private final ValidationReportService validationReportService; + private final ValidationReportCsvWriter validationReportCsvWriter; + + ValidationReportController( + ValidationReportService validationReportService, + ValidationReportCsvWriter validationReportCsvWriter) { + this.validationReportService = validationReportService; + this.validationReportCsvWriter = validationReportCsvWriter; + } + + @GetMapping("/api/validation-runs/{runId}/report") + ResponseEntity getReport(@PathVariable UUID runId, HttpServletRequest request) { + String[] formatValues = request.getParameterValues("format"); + ReportFormat format = ReportFormat.parse(formatValues == null ? null : List.of(formatValues)); + + ValidationReport report = validationReportService.getReport(runId); + ResponseEntity response = + switch (format) { + case JSON -> downloadResponse(runId, format, MediaType.APPLICATION_JSON).body(report); + case CSV -> + downloadResponse(runId, format, CSV_MEDIA_TYPE) + .body(validationReportCsvWriter.write(report)); + }; + + return response; + } + + private ResponseEntity.BodyBuilder downloadResponse( + UUID runId, ReportFormat format, MediaType mediaType) { + String filename = "validation-run-" + runId + "-report." + format.queryValue(); + String contentDisposition = + ContentDisposition.attachment().filename(filename).build().toString(); + + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) + .header(HttpHeaders.CACHE_CONTROL, "no-store"); + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriter.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriter.java new file mode 100644 index 0000000..a219461 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriter.java @@ -0,0 +1,100 @@ +package io.github.nktogo.dataquality.reporting; + +import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; +import io.github.nktogo.dataquality.validation.ValidationIssueResponse; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.springframework.stereotype.Component; + +@Component +class ValidationReportCsvWriter { + + private static final String[] HEADERS = { + "validation_run_id", + "dataset_id", + "source_file_id", + "profile_id", + "status", + "total_rows", + "valid_rows", + "invalid_rows", + "issue_count", + "started_at", + "finished_at", + "failure_reason", + "issue_present", + "issue_id", + "issue_run_id", + "row_number", + "field_name", + "rule_type", + "severity", + "message", + "observed_value_present", + "observed_value" + }; + + private static final CSVFormat CSV_FORMAT = + CSVFormat.RFC4180 + .builder() + .setHeader(HEADERS) + .setSkipHeaderRecord(false) + .setRecordSeparator("\r\n") + .get(); + + byte[] write(ValidationReport report) { + StringWriter output = new StringWriter(); + try (CSVPrinter printer = new CSVPrinter(output, CSV_FORMAT)) { + if (report.issues().isEmpty()) { + printRecord(printer, report.validationRun(), null); + } else { + for (ValidationIssueResponse issue : report.issues()) { + printRecord(printer, report.validationRun(), issue); + } + } + } catch (IOException exception) { + throw new UncheckedIOException("Validation report CSV could not be generated.", exception); + } + + return output.toString().getBytes(StandardCharsets.UTF_8); + } + + private void printRecord( + CSVPrinter printer, ValidationRunResponse validationRun, ValidationIssueResponse issue) + throws IOException { + boolean issuePresent = issue != null; + String observedValue = issuePresent ? issue.observedValue() : null; + + printer.printRecord( + validationRun.id(), + validationRun.datasetId(), + validationRun.sourceFileId(), + validationRun.profileId(), + validationRun.status(), + validationRun.totalRows(), + validationRun.validRows(), + validationRun.invalidRows(), + validationRun.issueCount(), + valueOrEmpty(validationRun.startedAt()), + valueOrEmpty(validationRun.finishedAt()), + valueOrEmpty(validationRun.failureReason()), + issuePresent, + issuePresent ? issue.id() : "", + issuePresent ? issue.runId() : "", + issuePresent ? issue.rowNumber() : "", + issuePresent ? issue.fieldName() : "", + issuePresent ? issue.ruleType() : "", + issuePresent ? issue.severity() : "", + issuePresent ? issue.message() : "", + observedValue != null, + valueOrEmpty(observedValue)); + } + + private Object valueOrEmpty(Object value) { + return value == null ? "" : value; + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportExceptionHandler.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportExceptionHandler.java new file mode 100644 index 0000000..d1b74e5 --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportExceptionHandler.java @@ -0,0 +1,23 @@ +package io.github.nktogo.dataquality.reporting; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ValidationReportController.class) +class ValidationReportExceptionHandler { + + @ExceptionHandler(InvalidReportFormatException.class) + ProblemDetail handleInvalidReportFormat( + InvalidReportFormatException exception, HttpServletRequest request) { + ProblemDetail problemDetail = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage()); + problemDetail.setTitle("Invalid report format"); + problemDetail.setInstance(URI.create(request.getRequestURI())); + + return problemDetail; + } +} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ReportFormatTests.java b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ReportFormatTests.java new file mode 100644 index 0000000..3057a36 --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ReportFormatTests.java @@ -0,0 +1,42 @@ +package io.github.nktogo.dataquality.reporting; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ReportFormatTests { + + @Test + void acceptsExactlyOneLowercaseSupportedValue() { + assertThat(ReportFormat.parse(List.of("json"))).isEqualTo(ReportFormat.JSON); + assertThat(ReportFormat.parse(List.of("csv"))).isEqualTo(ReportFormat.CSV); + } + + @ParameterizedTest + @MethodSource("invalidValues") + void rejectsEveryOtherQueryValueShape(List values) { + assertThatThrownBy(() -> ReportFormat.parse(values)) + .isInstanceOf(InvalidReportFormatException.class) + .hasMessage("Query parameter 'format' must be exactly one of: json, csv."); + } + + private static Stream invalidValues() { + return Stream.of( + Arguments.of((Object) null), + Arguments.of(List.of()), + Arguments.of(List.of("")), + Arguments.of(List.of(" ")), + Arguments.of(List.of("JSON")), + Arguments.of(List.of("Csv")), + Arguments.of(List.of("xml")), + Arguments.of(List.of("json ")), + Arguments.of(List.of("json", "csv")), + Arguments.of(List.of("json", "json"))); + } +} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriterTests.java b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriterTests.java new file mode 100644 index 0000000..b27ade6 --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportCsvWriterTests.java @@ -0,0 +1,268 @@ +package io.github.nktogo.dataquality.reporting; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.nktogo.dataquality.dataset.ValidationRuleSeverity; +import io.github.nktogo.dataquality.dataset.ValidationRuleType; +import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; +import io.github.nktogo.dataquality.ingestion.ValidationRunStatus; +import io.github.nktogo.dataquality.validation.ValidationIssueResponse; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.junit.jupiter.api.Test; + +class ValidationReportCsvWriterTests { + + private static final List EXPECTED_HEADERS = + List.of( + "validation_run_id", + "dataset_id", + "source_file_id", + "profile_id", + "status", + "total_rows", + "valid_rows", + "invalid_rows", + "issue_count", + "started_at", + "finished_at", + "failure_reason", + "issue_present", + "issue_id", + "issue_run_id", + "row_number", + "field_name", + "rule_type", + "severity", + "message", + "observed_value_present", + "observed_value"); + + private static final Instant STARTED_AT = Instant.parse("2026-08-11T12:00:00.123456Z"); + private static final Instant FINISHED_AT = Instant.parse("2026-08-11T12:00:02.123456Z"); + + private final ValidationReportCsvWriter writer = new ValidationReportCsvWriter(); + + @Test + void writesExactHeadersUtf8CrLfAndPreservesIssueOrderAndSpecialText() throws IOException { + UUID runId = UUID.randomUUID(); + String failureReason = "Failure, \"quoted\"\r\nnext"; + String firstMessage = "Markup , then\r\nnext"; + String firstObservedValue = + "=HYPERLINK(\"https://example.invalid\")\r\nGr\u00fc\u00dfe \u6771\u4eac \t "; + ValidationRunResponse run = failedRun(runId, failureReason); + ValidationIssueResponse first = + issue( + UUID.randomUUID(), + runId, + 2, + " first,\"field\" ", + ValidationRuleType.DATA_TYPE, + ValidationRuleSeverity.WARNING, + firstMessage, + firstObservedValue); + ValidationIssueResponse second = + issue( + UUID.randomUUID(), + runId, + 4, + "second", + ValidationRuleType.UNIQUENESS, + ValidationRuleSeverity.ERROR, + "Second", + " \t "); + + ValidationReport report = new ValidationReport(run, List.of(first, second)); + byte[] bytes = writer.write(report); + byte[] repeatedBytes = writer.write(report); + String csv = new String(bytes, StandardCharsets.UTF_8); + ParsedCsv parsed = parse(csv); + + assertThat(bytes[0]).isEqualTo((byte) 'v'); + assertThat(repeatedBytes).containsExactly(bytes); + assertThat(csv).endsWith("\r\n"); + assertThat(parsed.headers()).containsExactlyElementsOf(EXPECTED_HEADERS); + assertThat(parsed.records()).hasSize(2); + assertRunColumns(parsed.records().getFirst(), run); + assertIssueColumns(parsed.records().getFirst(), first); + assertRunColumns(parsed.records().get(1), run); + assertIssueColumns(parsed.records().get(1), second); + assertThat(parsed.records().getFirst().get("failure_reason")).isEqualTo(failureReason); + assertThat(parsed.records().getFirst().get("message")).isEqualTo(firstMessage); + assertThat(parsed.records().getFirst().get("observed_value")) + .isEqualTo(firstObservedValue) + .startsWith("="); + assertThat(parsed.records().get(1).get("observed_value")).isEqualTo(" \t "); + } + + @Test + void distinguishesNullEmptyAndPresentObservedValues() throws IOException { + UUID runId = UUID.randomUUID(); + ValidationRunResponse run = completedRun(runId, 2); + ValidationIssueResponse nullValue = + issue( + UUID.randomUUID(), + runId, + 2, + "null-value", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "Null", + null); + ValidationIssueResponse emptyValue = + issue( + UUID.randomUUID(), + runId, + 3, + "empty-value", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "Empty", + ""); + + ParsedCsv parsed = + parse( + new String( + writer.write(new ValidationReport(run, List.of(nullValue, emptyValue))), + StandardCharsets.UTF_8)); + + assertThat(parsed.records().getFirst().get("observed_value_present")).isEqualTo("false"); + assertThat(parsed.records().getFirst().get("observed_value")).isEmpty(); + assertThat(parsed.records().get(1).get("observed_value_present")).isEqualTo("true"); + assertThat(parsed.records().get(1).get("observed_value")).isEmpty(); + } + + @Test + void writesOneRunOnlyRecordWhenThereAreNoIssues() throws IOException { + UUID runId = UUID.randomUUID(); + ValidationRunResponse run = + new ValidationRunResponse( + runId, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + ValidationRunStatus.PENDING, + 0, + 0, + 0, + 0, + null, + null, + null); + + ParsedCsv parsed = + parse( + new String(writer.write(new ValidationReport(run, List.of())), StandardCharsets.UTF_8)); + + assertThat(parsed.records()).hasSize(1); + CSVRecord record = parsed.records().getFirst(); + assertRunColumns(record, run); + assertThat(record.get("issue_present")).isEqualTo("false"); + assertThat(record.get("observed_value_present")).isEqualTo("false"); + assertThat( + List.of( + "issue_id", + "issue_run_id", + "row_number", + "field_name", + "rule_type", + "severity", + "message", + "observed_value")) + .allSatisfy(header -> assertThat(record.get(header)).isEmpty()); + } + + private ParsedCsv parse(String csv) throws IOException { + CSVFormat format = CSVFormat.RFC4180.builder().setHeader().setSkipHeaderRecord(true).get(); + try (CSVParser parser = format.parse(new StringReader(csv))) { + return new ParsedCsv(parser.getHeaderNames(), parser.getRecords()); + } + } + + private void assertRunColumns(CSVRecord record, ValidationRunResponse run) { + assertThat(record.get("validation_run_id")).isEqualTo(run.id().toString()); + assertThat(record.get("dataset_id")).isEqualTo(run.datasetId().toString()); + assertThat(record.get("source_file_id")).isEqualTo(run.sourceFileId().toString()); + assertThat(record.get("profile_id")).isEqualTo(run.profileId().toString()); + assertThat(record.get("status")).isEqualTo(run.status().name()); + assertThat(record.get("total_rows")).isEqualTo(Long.toString(run.totalRows())); + assertThat(record.get("valid_rows")).isEqualTo(Long.toString(run.validRows())); + assertThat(record.get("invalid_rows")).isEqualTo(Long.toString(run.invalidRows())); + assertThat(record.get("issue_count")).isEqualTo(Long.toString(run.issueCount())); + assertThat(record.get("started_at")) + .isEqualTo(run.startedAt() == null ? "" : run.startedAt().toString()); + assertThat(record.get("finished_at")) + .isEqualTo(run.finishedAt() == null ? "" : run.finishedAt().toString()); + assertThat(record.get("failure_reason")) + .isEqualTo(run.failureReason() == null ? "" : run.failureReason()); + } + + private void assertIssueColumns(CSVRecord record, ValidationIssueResponse issue) { + assertThat(record.get("issue_present")).isEqualTo("true"); + assertThat(record.get("issue_id")).isEqualTo(issue.id().toString()); + assertThat(record.get("issue_run_id")).isEqualTo(issue.runId().toString()); + assertThat(record.get("row_number")).isEqualTo(Long.toString(issue.rowNumber())); + assertThat(record.get("field_name")).isEqualTo(issue.fieldName()); + assertThat(record.get("rule_type")).isEqualTo(issue.ruleType().name()); + assertThat(record.get("severity")).isEqualTo(issue.severity().name()); + assertThat(record.get("message")).isEqualTo(issue.message()); + assertThat(record.get("observed_value_present")) + .isEqualTo(Boolean.toString(issue.observedValue() != null)); + assertThat(record.get("observed_value")) + .isEqualTo(issue.observedValue() == null ? "" : issue.observedValue()); + } + + private ValidationRunResponse completedRun(UUID runId, long issueCount) { + return new ValidationRunResponse( + runId, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + ValidationRunStatus.COMPLETED, + 4, + 2, + 2, + issueCount, + STARTED_AT, + FINISHED_AT, + null); + } + + private ValidationRunResponse failedRun(UUID runId, String failureReason) { + return new ValidationRunResponse( + runId, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + ValidationRunStatus.FAILED, + 4, + 0, + 0, + 0, + STARTED_AT, + FINISHED_AT, + failureReason); + } + + private ValidationIssueResponse issue( + UUID id, + UUID runId, + long rowNumber, + String fieldName, + ValidationRuleType ruleType, + ValidationRuleSeverity severity, + String message, + String observedValue) { + return new ValidationIssueResponse( + id, runId, rowNumber, fieldName, ruleType, severity, message, observedValue); + } + + private record ParsedCsv(List headers, List records) {} +} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportIntegrationTests.java b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportIntegrationTests.java new file mode 100644 index 0000000..1a090a7 --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportIntegrationTests.java @@ -0,0 +1,700 @@ +package io.github.nktogo.dataquality.reporting; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import io.github.nktogo.dataquality.dataset.ValidationRuleSeverity; +import io.github.nktogo.dataquality.dataset.ValidationRuleType; +import io.github.nktogo.dataquality.ingestion.ValidationRunStatus; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +class ValidationReportIntegrationTests { + + private static final byte[] CONTENT_BYTES = + "name,age\r\nAlice,42\r\n".getBytes(StandardCharsets.UTF_8); + private static final Instant CREATED_AT = Instant.parse("2026-08-11T11:00:00.123456Z"); + private static final Instant STARTED_AT = Instant.parse("2026-08-11T12:00:00.123456Z"); + private static final Instant FINISHED_AT = Instant.parse("2026-08-11T12:00:02.123456Z"); + private static final MediaType CSV_MEDIA_TYPE = + new MediaType("text", "csv", StandardCharsets.UTF_8); + private static final String INVALID_FORMAT_DETAIL = + "Query parameter 'format' must be exactly one of: json, csv."; + + @Container @ServiceConnection + private static final PostgreSQLContainer postgres = + new PostgreSQLContainer("postgres:18.4-alpine"); + + @Autowired private MockMvc mockMvc; + + @Autowired private JdbcTemplate jdbcTemplate; + + @BeforeEach + void deleteReportsRunsAndParents() { + jdbcTemplate.update("delete from validation_issue"); + jdbcTemplate.update("delete from validation_run"); + jdbcTemplate.update("delete from validation_rule"); + jdbcTemplate.update("delete from source_file"); + jdbcTemplate.update("delete from validation_profile"); + jdbcTemplate.update("delete from dataset"); + } + + @Test + void exportsExactJsonContractHeadersAndPersistedIssueOrder() throws Exception { + RunFixture run = insertRun(ValidationRunStatus.COMPLETED, 5, 3, 2, 3); + IssueFixture third = + insertIssue( + UUID.randomUUID(), + run.id(), + 3, + "same", + ValidationRuleType.UNIQUENESS, + ValidationRuleSeverity.ERROR, + "Formula-like value is persisted literally.", + "=2+3"); + IssueFixture second = + insertIssue( + UUID.randomUUID(), + run.id(), + 2, + "beta", + ValidationRuleType.DATA_TYPE, + ValidationRuleSeverity.WARNING, + "An empty value remains distinct from null.", + ""); + IssueFixture first = + insertIssue( + UUID.randomUUID(), + run.id(), + 2, + "alpha", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "Markup renders as data.", + null); + + var response = + mockMvc + .perform( + get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "json")) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect( + header() + .string( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"validation-run-" + run.id() + "-report.json\"")) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$", aMapWithSize(2))) + .andExpect(jsonPath("$.validationRun", aMapWithSize(12))) + .andExpect(jsonPath("$.validationRun.id").value(run.id().toString())) + .andExpect(jsonPath("$.validationRun.datasetId").value(run.datasetId().toString())) + .andExpect( + jsonPath("$.validationRun.sourceFileId").value(run.sourceFileId().toString())) + .andExpect(jsonPath("$.validationRun.profileId").value(run.profileId().toString())) + .andExpect(jsonPath("$.validationRun.status").value("COMPLETED")) + .andExpect(jsonPath("$.validationRun.totalRows").value(5)) + .andExpect(jsonPath("$.validationRun.validRows").value(3)) + .andExpect(jsonPath("$.validationRun.invalidRows").value(2)) + .andExpect(jsonPath("$.validationRun.issueCount").value(3)) + .andExpect(jsonPath("$.validationRun.startedAt").value(STARTED_AT.toString())) + .andExpect(jsonPath("$.validationRun.finishedAt").value(FINISHED_AT.toString())) + .andExpect(jsonPath("$.validationRun.failureReason").value(nullValue())) + .andExpect(jsonPath("$.issues", hasSize(3))); + + assertJsonIssue(response, 0, first); + assertJsonIssue(response, 1, second); + assertJsonIssue(response, 2, third); + } + + @Test + void exportsExactCsvContractAndPreservesSpecialValues() throws Exception { + RunFixture run = insertRun(ValidationRunStatus.COMPLETED, 4, 2, 2, 3); + IssueFixture formula = + insertIssue( + UUID.randomUUID(), + run.id(), + 4, + "zeta", + ValidationRuleType.NUMERIC_RANGE, + ValidationRuleSeverity.ERROR, + "Comma, quote \" and CRLF\r\nremain data.", + "=HYPERLINK(\"https://example.invalid\")"); + IssueFixture empty = + insertIssue( + UUID.randomUUID(), + run.id(), + 2, + "beta", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.WARNING, + "Empty", + ""); + IssueFixture whitespace = + insertIssue( + UUID.randomUUID(), + run.id(), + 2, + "alpha", + ValidationRuleType.DATA_TYPE, + ValidationRuleSeverity.ERROR, + "Unicode Gr\u00fc\u00dfe \u6771\u4eac and .", + " \t "); + + byte[] responseBytes = + mockMvc + .perform( + get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "csv")) + .andExpect(status().isOk()) + .andExpect(content().contentType(CSV_MEDIA_TYPE)) + .andExpect( + header() + .string( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"validation-run-" + run.id() + "-report.csv\"")) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andReturn() + .getResponse() + .getContentAsByteArray(); + + assertThat(responseBytes[0]).isEqualTo((byte) 'v'); + String csv = new String(responseBytes, StandardCharsets.UTF_8); + assertThat(csv).endsWith("\r\n"); + ParsedCsv parsed = parse(csv); + assertThat(parsed.headers()) + .containsExactly( + "validation_run_id", + "dataset_id", + "source_file_id", + "profile_id", + "status", + "total_rows", + "valid_rows", + "invalid_rows", + "issue_count", + "started_at", + "finished_at", + "failure_reason", + "issue_present", + "issue_id", + "issue_run_id", + "row_number", + "field_name", + "rule_type", + "severity", + "message", + "observed_value_present", + "observed_value"); + assertThat(parsed.records()).hasSize(3); + assertCsvIssue(parsed.records().get(0), run, whitespace, true); + assertCsvIssue(parsed.records().get(1), run, empty, true); + assertCsvIssue(parsed.records().get(2), run, formula, true); + assertThat(parsed.records().get(2).get("observed_value")) + .isEqualTo(formula.observedValue()) + .startsWith("="); + } + + @Test + void jsonAndCsvRepresentTheSamePersistedSnapshot() throws Exception { + RunFixture run = insertRun(ValidationRunStatus.COMPLETED, 3, 2, 1, 1); + insertIssue( + UUID.randomUUID(), + run.id(), + 3, + "email", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "Value is required.", + ""); + + String json = + mockMvc + .perform( + get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "json")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + String csv = + mockMvc + .perform( + get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "csv")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + ParsedCsv parsed = parse(csv); + assertThat(parsed.records()).hasSize(1); + assertJsonCsvParity(json, parsed.records().getFirst()); + } + + @ParameterizedTest + @EnumSource(ValidationRunStatus.class) + void exportsEveryPersistedStatusWithoutIssues(ValidationRunStatus statusValue) throws Exception { + RunFixture run = + insertRun(statusValue, statusValue == ValidationRunStatus.PROCESSING ? 5 : 0, 0, 0, 0); + + mockMvc + .perform(get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.validationRun.status").value(statusValue.name())) + .andExpect(jsonPath("$.issues").isEmpty()); + } + + @Test + void csvWithoutIssuesContainsOneRunOnlyRowAndExplicitPresenceFlags() throws Exception { + RunFixture run = insertRun(ValidationRunStatus.PENDING, 0, 0, 0, 0); + + byte[] responseBytes = + mockMvc + .perform( + get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "csv")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsByteArray(); + ParsedCsv parsed = parse(new String(responseBytes, StandardCharsets.UTF_8)); + + assertThat(parsed.records()).hasSize(1); + CSVRecord record = parsed.records().getFirst(); + assertCsvRun(record, run); + assertThat(record.get("issue_present")).isEqualTo("false"); + assertThat(record.get("observed_value_present")).isEqualTo("false"); + assertThat( + List.of( + "issue_id", + "issue_run_id", + "row_number", + "field_name", + "rule_type", + "severity", + "message", + "observed_value")) + .allSatisfy(headerName -> assertThat(record.get(headerName)).isEmpty()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidFormatRequests") + void returnsApplicationOwnedProblemDetailForEveryInvalidFormatShape( + String description, List formatValues) throws Exception { + UUID unknownRunId = UUID.randomUUID(); + MockHttpServletRequestBuilder request = + get("/api/validation-runs/{runId}/report", unknownRunId); + if (formatValues != null) { + request.queryParam("format", formatValues.toArray(String[]::new)); + } + + mockMvc + .perform(request) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$", aMapWithSize(4))) + .andExpect(jsonPath("$.type").doesNotExist()) + .andExpect(jsonPath("$.title").value("Invalid report format")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value(INVALID_FORMAT_DETAIL)) + .andExpect( + jsonPath("$.instance").value("/api/validation-runs/" + unknownRunId + "/report")); + } + + @ParameterizedTest + @MethodSource("supportedFormats") + void reusesValidationRunNotFoundProblemDetailForUnknownRun(String format) throws Exception { + UUID runId = UUID.randomUUID(); + + mockMvc + .perform(get("/api/validation-runs/{runId}/report", runId).queryParam("format", format)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$", aMapWithSize(4))) + .andExpect(jsonPath("$.type").doesNotExist()) + .andExpect(jsonPath("$.title").value("Validation Run not found")) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.detail").value("Validation Run '" + runId + "' was not found.")) + .andExpect(jsonPath("$.instance").value("/api/validation-runs/" + runId + "/report")); + } + + @Test + void rejectsMalformedRunIdAndDoesNotExposeAWriter() throws Exception { + mockMvc + .perform(get("/api/validation-runs/not-a-uuid/report").queryParam("format", "json")) + .andExpect(status().isBadRequest()); + mockMvc + .perform( + post("/api/validation-runs/{runId}/report", UUID.randomUUID()) + .queryParam("format", "json")) + .andExpect(status().isMethodNotAllowed()); + } + + @Test + void bothExportsLeaveThePersistedRunAndIssuesUnchanged() throws Exception { + RunFixture run = insertRun(ValidationRunStatus.COMPLETED, 2, 1, 1, 1); + insertIssue( + UUID.randomUUID(), + run.id(), + 2, + "name", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "Value is required.", + ""); + List> runsBefore = readRuns(run.id()); + List> issuesBefore = readIssues(run.id()); + + mockMvc + .perform(get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "json")) + .andExpect(status().isOk()); + mockMvc + .perform(get("/api/validation-runs/{runId}/report", run.id()).queryParam("format", "csv")) + .andExpect(status().isOk()); + + assertThat(readRuns(run.id())).containsExactlyElementsOf(runsBefore); + assertThat(readIssues(run.id())).containsExactlyElementsOf(issuesBefore); + } + + private static Stream invalidFormatRequests() { + return Stream.of( + Arguments.of("missing", null), + Arguments.of("blank", List.of("")), + Arguments.of("whitespace", List.of(" ")), + Arguments.of("uppercase", List.of("JSON")), + Arguments.of("mixed case", List.of("Csv")), + Arguments.of("unsupported", List.of("xml")), + Arguments.of("trailing whitespace", List.of("json ")), + Arguments.of("repeated alternatives", List.of("json", "csv")), + Arguments.of("repeated same value", List.of("json", "json")), + Arguments.of("comma separated", List.of("json,csv"))); + } + + private static Stream supportedFormats() { + return Stream.of("json", "csv"); + } + + private void assertJsonIssue( + org.springframework.test.web.servlet.ResultActions response, int index, IssueFixture expected) + throws Exception { + String path = "$.issues[" + index + "]"; + response + .andExpect(jsonPath(path, aMapWithSize(8))) + .andExpect(jsonPath(path + ".id").value(expected.id().toString())) + .andExpect(jsonPath(path + ".runId").value(expected.runId().toString())) + .andExpect(jsonPath(path + ".rowNumber").value(expected.rowNumber())) + .andExpect(jsonPath(path + ".fieldName").value(expected.fieldName())) + .andExpect(jsonPath(path + ".ruleType").value(expected.ruleType().name())) + .andExpect(jsonPath(path + ".severity").value(expected.severity().name())) + .andExpect(jsonPath(path + ".message").value(expected.message())); + if (expected.observedValue() == null) { + response.andExpect(jsonPath(path + ".observedValue").value(nullValue())); + } else { + response.andExpect(jsonPath(path + ".observedValue").value(expected.observedValue())); + } + } + + private ParsedCsv parse(String csv) throws IOException { + CSVFormat format = CSVFormat.RFC4180.builder().setHeader().setSkipHeaderRecord(true).get(); + try (CSVParser parser = format.parse(new StringReader(csv))) { + return new ParsedCsv(parser.getHeaderNames(), parser.getRecords()); + } + } + + private void assertCsvIssue( + CSVRecord record, RunFixture run, IssueFixture issue, boolean observedValuePresent) { + assertCsvRun(record, run); + assertThat(record.get("issue_present")).isEqualTo("true"); + assertThat(record.get("issue_id")).isEqualTo(issue.id().toString()); + assertThat(record.get("issue_run_id")).isEqualTo(issue.runId().toString()); + assertThat(record.get("row_number")).isEqualTo(Long.toString(issue.rowNumber())); + assertThat(record.get("field_name")).isEqualTo(issue.fieldName()); + assertThat(record.get("rule_type")).isEqualTo(issue.ruleType().name()); + assertThat(record.get("severity")).isEqualTo(issue.severity().name()); + assertThat(record.get("message")).isEqualTo(issue.message()); + assertThat(record.get("observed_value_present")) + .isEqualTo(Boolean.toString(observedValuePresent)); + assertThat(record.get("observed_value")) + .isEqualTo(issue.observedValue() == null ? "" : issue.observedValue()); + } + + private void assertCsvRun(CSVRecord record, RunFixture run) { + assertThat(record.get("validation_run_id")).isEqualTo(run.id().toString()); + assertThat(record.get("dataset_id")).isEqualTo(run.datasetId().toString()); + assertThat(record.get("source_file_id")).isEqualTo(run.sourceFileId().toString()); + assertThat(record.get("profile_id")).isEqualTo(run.profileId().toString()); + assertThat(record.get("status")).isEqualTo(run.status().name()); + assertThat(record.get("total_rows")).isEqualTo(Long.toString(run.totalRows())); + assertThat(record.get("valid_rows")).isEqualTo(Long.toString(run.validRows())); + assertThat(record.get("invalid_rows")).isEqualTo(Long.toString(run.invalidRows())); + assertThat(record.get("issue_count")).isEqualTo(Long.toString(run.issueCount())); + assertThat(record.get("started_at")) + .isEqualTo(run.startedAt() == null ? "" : run.startedAt().toString()); + assertThat(record.get("finished_at")) + .isEqualTo(run.finishedAt() == null ? "" : run.finishedAt().toString()); + assertThat(record.get("failure_reason")) + .isEqualTo(run.failureReason() == null ? "" : run.failureReason()); + } + + @SuppressWarnings("unchecked") + private void assertJsonCsvParity(String json, CSVRecord csv) { + Map validationRun = JsonPath.read(json, "$.validationRun"); + List> issues = JsonPath.read(json, "$.issues"); + assertThat(issues).hasSize(1); + Map issue = issues.getFirst(); + + Map runColumns = + Map.ofEntries( + Map.entry("validation_run_id", "id"), + Map.entry("dataset_id", "datasetId"), + Map.entry("source_file_id", "sourceFileId"), + Map.entry("profile_id", "profileId"), + Map.entry("status", "status"), + Map.entry("total_rows", "totalRows"), + Map.entry("valid_rows", "validRows"), + Map.entry("invalid_rows", "invalidRows"), + Map.entry("issue_count", "issueCount"), + Map.entry("started_at", "startedAt"), + Map.entry("finished_at", "finishedAt"), + Map.entry("failure_reason", "failureReason")); + Map issueColumns = + Map.ofEntries( + Map.entry("issue_id", "id"), + Map.entry("issue_run_id", "runId"), + Map.entry("row_number", "rowNumber"), + Map.entry("field_name", "fieldName"), + Map.entry("rule_type", "ruleType"), + Map.entry("severity", "severity"), + Map.entry("message", "message"), + Map.entry("observed_value", "observedValue")); + + runColumns.forEach( + (csvColumn, jsonField) -> + assertThat(csv.get(csvColumn)).isEqualTo(csvValue(validationRun.get(jsonField)))); + issueColumns.forEach( + (csvColumn, jsonField) -> + assertThat(csv.get(csvColumn)).isEqualTo(csvValue(issue.get(jsonField)))); + assertThat(csv.get("issue_present")).isEqualTo("true"); + assertThat(csv.get("observed_value_present")) + .isEqualTo(Boolean.toString(issue.get("observedValue") != null)); + } + + private String csvValue(Object jsonValue) { + return jsonValue == null ? "" : jsonValue.toString(); + } + + private RunFixture insertRun( + ValidationRunStatus status, + long totalRows, + long validRows, + long invalidRows, + long issueCount) { + UUID datasetId = insertDataset(); + UUID sourceFileId = insertSourceFile(datasetId); + UUID profileId = insertValidationProfile(datasetId); + UUID runId = UUID.randomUUID(); + Instant startedAt = status == ValidationRunStatus.PENDING ? null : STARTED_AT; + Instant finishedAt = + status == ValidationRunStatus.COMPLETED || status == ValidationRunStatus.FAILED + ? FINISHED_AT + : null; + String failureReason = + status == ValidationRunStatus.FAILED ? "Persisted validation failure." : null; + + jdbcTemplate.update( + """ + insert into validation_run + (id, dataset_id, source_file_id, profile_id, status, total_rows, valid_rows, + invalid_rows, issue_count, started_at, finished_at, failure_reason) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + runId, + datasetId, + sourceFileId, + profileId, + status.name(), + totalRows, + validRows, + invalidRows, + issueCount, + startedAt == null ? null : Timestamp.from(startedAt), + finishedAt == null ? null : Timestamp.from(finishedAt), + failureReason); + + return new RunFixture( + runId, + datasetId, + sourceFileId, + profileId, + status, + totalRows, + validRows, + invalidRows, + issueCount, + startedAt, + finishedAt, + failureReason); + } + + private UUID insertDataset() { + UUID datasetId = UUID.randomUUID(); + jdbcTemplate.update( + "insert into dataset (id, name, description, created_at) values (?, ?, ?, ?)", + datasetId, + "Reporting test dataset", + null, + Timestamp.from(CREATED_AT)); + return datasetId; + } + + private UUID insertSourceFile(UUID datasetId) { + UUID sourceFileId = UUID.randomUUID(); + jdbcTemplate.update( + """ + insert into source_file + (id, dataset_id, original_filename, content_type, size_bytes, sha256, + content_bytes, uploaded_at) + values (?, ?, ?, ?, ?, ?, ?, ?) + """, + sourceFileId, + datasetId, + "report.csv", + "text/csv", + CONTENT_BYTES.length, + sha256(CONTENT_BYTES), + CONTENT_BYTES, + Timestamp.from(CREATED_AT)); + return sourceFileId; + } + + private UUID insertValidationProfile(UUID datasetId) { + UUID profileId = UUID.randomUUID(); + jdbcTemplate.update( + """ + insert into validation_profile (id, dataset_id, name, created_at) + values (?, ?, ?, ?) + """, + profileId, + datasetId, + "Reporting test profile", + Timestamp.from(CREATED_AT)); + return profileId; + } + + private IssueFixture insertIssue( + UUID id, + UUID runId, + long rowNumber, + String fieldName, + ValidationRuleType ruleType, + ValidationRuleSeverity severity, + String message, + String observedValue) { + jdbcTemplate.update( + """ + insert into validation_issue + (id, run_id, row_number, field_name, rule_type, severity, message, observed_value) + values (?, ?, ?, ?, ?, ?, ?, ?) + """, + id, + runId, + rowNumber, + fieldName, + ruleType.name(), + severity.name(), + message, + observedValue); + return new IssueFixture( + id, runId, rowNumber, fieldName, ruleType, severity, message, observedValue); + } + + private List> readRuns(UUID runId) { + return jdbcTemplate.queryForList( + "select * from validation_run where id = ? order by id", runId); + } + + private List> readIssues(UUID runId) { + return jdbcTemplate.queryForList( + """ + select * from validation_issue + where run_id = ? + order by row_number, field_name, rule_type, id + """, + runId); + } + + private String sha256(byte[] contentBytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(contentBytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available.", exception); + } + } + + private record ParsedCsv(List headers, List records) {} + + private record RunFixture( + UUID id, + UUID datasetId, + UUID sourceFileId, + UUID profileId, + ValidationRunStatus status, + long totalRows, + long validRows, + long invalidRows, + long issueCount, + Instant startedAt, + Instant finishedAt, + String failureReason) {} + + private record IssueFixture( + UUID id, + UUID runId, + long rowNumber, + String fieldName, + ValidationRuleType ruleType, + ValidationRuleSeverity severity, + String message, + String observedValue) {} +} From d95bf69af93f1ffacebb32a0fc62524803df234a Mon Sep 17 00:00:00 2001 From: NkToGo Date: Thu, 13 Aug 2026 15:25:54 +0200 Subject: [PATCH 3/5] Milestone 6 slice 3: add managed report downloads --- frontend/index.html | 2 +- frontend/src/App.test.tsx | 2 + frontend/src/App.tsx | 6 +- frontend/src/api/client.test.ts | 73 ++++++++- frontend/src/api/client.ts | 55 ++++++- frontend/src/api/contracts.ts | 3 + .../ValidationRunReportExports.test.tsx | 145 +++++++++++++++++ .../components/ValidationRunReportExports.tsx | 149 ++++++++++++++++++ .../downloads/saveValidationRunReport.test.ts | 62 ++++++++ .../src/downloads/saveValidationRunReport.ts | 36 +++++ .../pages/ValidationRunDetailPage.test.tsx | 6 + .../src/pages/ValidationRunDetailPage.tsx | 2 + frontend/src/styles.css | 30 ++++ 13 files changed, 561 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/ValidationRunReportExports.test.tsx create mode 100644 frontend/src/components/ValidationRunReportExports.tsx create mode 100644 frontend/src/downloads/saveValidationRunReport.test.ts create mode 100644 frontend/src/downloads/saveValidationRunReport.ts diff --git a/frontend/index.html b/frontend/index.html index b190c41..8ff3e37 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ Data Quality Platform diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c1f4e3a..e411ed0 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -13,6 +13,7 @@ import { renderWithRouter } from './test/renderWithRouter'; vi.mock('./api/client', () => ({ getDatasets: vi.fn(), getValidationIssues: vi.fn(), + getValidationRunReport: vi.fn(), getValidationRun: vi.fn(), getValidationRuns: vi.fn(), })); @@ -44,6 +45,7 @@ describe('App routing', () => { renderWithRouter(, `/runs/${validationRunFixture.id}`); expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Export report' })).toBeInTheDocument(); expect(screen.getByText(validationRunFixture.id)).toBeInTheDocument(); expect(getValidationRunMock).toHaveBeenCalledWith( validationRunFixture.id, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abf7049..6a34aff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,11 +27,13 @@ function App() {
-

Milestone 5

+

Milestone 6

Data Quality Platform

-

Read-only Dataset and Validation Run dashboard.

+

+ Read-only Dataset and Validation Run dashboard with report exports. +

diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 6f4231c..6c09937 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -3,6 +3,7 @@ import { ApiError, getDatasets, getValidationIssues, + getValidationRunReport, getValidationRun, getValidationRuns, } from './client'; @@ -12,7 +13,12 @@ import { validationIssueFixture, validationRunFixture, } from '../test/fixtures'; -import type { ValidationIssueSeverity, ValidationRuleType, ValidationRunStatus } from './contracts'; +import type { + ValidationIssueSeverity, + ValidationReportFormat, + ValidationRuleType, + ValidationRunStatus, +} from './contracts'; const fetchMock = vi.fn(); @@ -190,6 +196,71 @@ describe('Data Quality API client', () => { }); }); + it.each<[ValidationReportFormat, string, string]>([ + ['json', 'application/json', '{"runId":"report-run"}'], + ['csv', 'text/csv', 'rowNumber,fieldName\r\n2,email\r\n'], + ])('downloads the exact %s report bytes', async (format, mediaType, body) => { + const controller = new AbortController(); + fetchMock.mockResolvedValueOnce( + new Response(body, { + status: 200, + headers: { 'Content-Type': `${mediaType}; charset=UTF-8` }, + }), + ); + + const report = await getValidationRunReport(validationRunFixture.id, format, controller.signal); + + expect(await report.text()).toBe(body); + expect(fetchMock).toHaveBeenCalledWith( + `/api/validation-runs/${validationRunFixture.id}/report?format=${format}`, + { + method: 'GET', + headers: { Accept: mediaType }, + signal: controller.signal, + }, + ); + }); + + it('rejects a successful report response with the wrong media type', async () => { + fetchMock.mockResolvedValueOnce( + new Response('{"runId":"report-run"}', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), + ); + + await expect(getValidationRunReport(validationRunFixture.id, 'json')).rejects.toEqual( + new ApiError('invalid-response', 'The Data Quality API returned an unexpected response.'), + ); + }); + + it('uses Problem Details for a report HTTP error', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(problemDetailsFixture, 404, 'application/problem+json'), + ); + + await expect(getValidationRunReport(validationRunFixture.id, 'csv')).rejects.toMatchObject({ + kind: 'http', + status: 404, + message: problemDetailsFixture.detail, + }); + }); + + it('reports a report network failure with the existing application-owned message', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Private connection details')); + + await expect(getValidationRunReport(validationRunFixture.id, 'json')).rejects.toEqual( + new ApiError('network', 'The Data Quality API could not be reached.'), + ); + }); + + it('preserves report request aborts', async () => { + const abortError = new DOMException('The operation was aborted.', 'AbortError'); + fetchMock.mockRejectedValueOnce(abortError); + + await expect(getValidationRunReport(validationRunFixture.id, 'csv')).rejects.toBe(abortError); + }); + it('uses the RFC Problem Details detail for an HTTP error', async () => { fetchMock.mockResolvedValueOnce( jsonResponse(problemDetailsFixture, 404, 'application/problem+json'), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 713cea7..9b067de 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,6 +6,7 @@ import { decodeValidationRuns, type Dataset, type ValidationIssue, + type ValidationReportFormat, type ValidationRun, } from './contracts'; @@ -28,6 +29,11 @@ type Decoder = (value: unknown) => T | null; const INVALID_RESPONSE_MESSAGE = 'The Data Quality API returned an unexpected response.'; const NETWORK_ERROR_MESSAGE = 'The Data Quality API could not be reached.'; +const REPORT_MEDIA_TYPES: Record = { + json: 'application/json', + csv: 'text/csv', +}; + async function parseJson(response: Response): Promise { const body = await response.text(); if (body.trim().length === 0) { @@ -42,7 +48,7 @@ async function parseJson(response: Response): Promise { } function httpError(response: Response, body: unknown): ApiError { - const contentType = response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase(); + const contentType = responseMediaType(response); const problemDetails = contentType === 'application/problem+json' ? decodeProblemDetails(body) : null; const message = @@ -53,10 +59,22 @@ function httpError(response: Response, body: unknown): ApiError { return new ApiError('http', message, response.status); } +function responseMediaType(response: Response): string | undefined { + return response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase(); +} + function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError'; } +function rethrowRequestError(error: unknown): never { + if (error instanceof ApiError || isAbortError(error)) { + throw error; + } + + throw new ApiError('network', NETWORK_ERROR_MESSAGE); +} + async function getJson(url: string, decoder: Decoder, signal?: AbortSignal): Promise { try { const response = await fetch(url, { @@ -77,11 +95,7 @@ async function getJson(url: string, decoder: Decoder, signal?: AbortSignal return decoded; } catch (error) { - if (error instanceof ApiError || isAbortError(error)) { - throw error; - } - - throw new ApiError('network', NETWORK_ERROR_MESSAGE); + rethrowRequestError(error); } } @@ -107,3 +121,32 @@ export function getValidationIssues( signal, ); } + +export async function getValidationRunReport( + runId: string, + format: ValidationReportFormat, + signal?: AbortSignal, +): Promise { + try { + const response = await fetch( + `/api/validation-runs/${encodeURIComponent(runId)}/report?format=${format}`, + { + method: 'GET', + headers: { Accept: REPORT_MEDIA_TYPES[format] }, + signal, + }, + ); + + if (!response.ok) { + throw httpError(response, await parseJson(response)); + } + + if (responseMediaType(response) !== REPORT_MEDIA_TYPES[format]) { + throw new ApiError('invalid-response', INVALID_RESPONSE_MESSAGE); + } + + return await response.blob(); + } catch (error) { + rethrowRequestError(error); + } +} diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 1be6bec..16f5005 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -15,6 +15,9 @@ export type ValidationRuleType = (typeof validationRuleTypes)[number]; export const validationIssueSeverities = ['ERROR', 'WARNING'] as const; export type ValidationIssueSeverity = (typeof validationIssueSeverities)[number]; +export const validationReportFormats = ['json', 'csv'] as const; +export type ValidationReportFormat = (typeof validationReportFormats)[number]; + export interface Dataset { id: string; name: string; diff --git a/frontend/src/components/ValidationRunReportExports.test.tsx b/frontend/src/components/ValidationRunReportExports.test.tsx new file mode 100644 index 0000000..840b58e --- /dev/null +++ b/frontend/src/components/ValidationRunReportExports.test.tsx @@ -0,0 +1,145 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getValidationRunReport } from '../api/client'; +import { saveValidationRunReport } from '../downloads/saveValidationRunReport'; +import { validationRunFixture } from '../test/fixtures'; +import { ValidationRunReportExports } from './ValidationRunReportExports'; + +vi.mock('../api/client', () => ({ + getValidationRunReport: vi.fn(), +})); + +vi.mock('../downloads/saveValidationRunReport', () => ({ + saveValidationRunReport: vi.fn(), +})); + +const getValidationRunReportMock = vi.mocked(getValidationRunReport); +const saveValidationRunReportMock = vi.mocked(saveValidationRunReport); + +function deferred() { + let resolvePromise: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { promise, resolve: resolvePromise }; +} + +describe('ValidationRunReportExports', () => { + beforeEach(() => { + getValidationRunReportMock.mockReset(); + saveValidationRunReportMock + .mockReset() + .mockReturnValue(`validation-run-${validationRunFixture.id}-report.json`); + }); + + it('downloads one JSON snapshot at a time and announces that it started', async () => { + const reportRequest = deferred(); + const report = new Blob(['{"status":"COMPLETED"}'], { type: 'application/json' }); + getValidationRunReportMock.mockReturnValue(reportRequest.promise); + + render(); + + expect(screen.getByRole('heading', { name: 'Export report' })).toBeInTheDocument(); + expect( + screen.getByText(/available for Pending, Processing, Completed, and Failed Runs/), + ).toBeInTheDocument(); + expect(screen.getByText(/current persisted state and may be incomplete/)).toBeInTheDocument(); + expect(screen.getByText(/filters affect only the table below/i)).toBeInTheDocument(); + + const formats = screen.getByRole('group', { name: 'Report download formats' }); + const jsonButton = within(formats).getByRole('button', { name: 'Download JSON report' }); + const csvButton = within(formats).getByRole('button', { name: 'Download CSV report' }); + jsonButton.focus(); + fireEvent.click(jsonButton); + + await waitFor(() => + expect(getValidationRunReportMock).toHaveBeenCalledWith( + validationRunFixture.id, + 'json', + expect.any(AbortSignal), + ), + ); + expect(screen.getByRole('status')).toHaveTextContent('Preparing JSON report download…'); + expect(jsonButton).toBeDisabled(); + expect(jsonButton).toHaveAttribute('aria-busy', 'true'); + expect(csvButton).toBeDisabled(); + fireEvent.click(csvButton); + expect(getValidationRunReportMock).toHaveBeenCalledOnce(); + + await act(async () => reportRequest.resolve(report)); + + expect(saveValidationRunReportMock).toHaveBeenCalledWith( + report, + validationRunFixture.id, + 'json', + ); + expect(await screen.findByRole('status')).toHaveTextContent( + `validation-run-${validationRunFixture.id}-report.json download started.`, + ); + expect(jsonButton).toBeEnabled(); + expect(csvButton).toBeEnabled(); + expect(jsonButton).toHaveFocus(); + }); + + it('shows a local format-specific error and retries the same report', async () => { + const report = new Blob(['rowNumber,fieldName\r\n'], { type: 'text/csv' }); + const retryRequest = deferred(); + getValidationRunReportMock + .mockRejectedValueOnce(new Error('The report endpoint could not be reached.')) + .mockReturnValueOnce(retryRequest.promise); + saveValidationRunReportMock.mockReturnValue( + `validation-run-${validationRunFixture.id}-report.csv`, + ); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Download CSV report' })); + + const alert = await screen.findByRole('alert'); + expect( + within(alert).getByRole('heading', { name: 'CSV report could not be downloaded' }), + ).toBeInTheDocument(); + expect(alert).toHaveTextContent('The report endpoint could not be reached.'); + + const retryButton = within(alert).getByRole('button', { + name: 'Retry CSV report download', + }); + retryButton.focus(); + fireEvent.click(retryButton); + + const csvButton = screen.getByRole('button', { name: 'Download CSV report' }); + expect(await screen.findByRole('status')).toHaveTextContent('Preparing CSV report download'); + expect(csvButton).toHaveFocus(); + + await act(async () => retryRequest.resolve(report)); + + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('download started.')); + expect(getValidationRunReportMock).toHaveBeenCalledTimes(2); + expect(getValidationRunReportMock.mock.calls.map((call) => call[1])).toEqual(['csv', 'csv']); + expect(saveValidationRunReportMock).toHaveBeenCalledWith( + report, + validationRunFixture.id, + 'csv', + ); + expect(csvButton).toHaveFocus(); + }); + + it('aborts an active report request when the exports panel unmounts', async () => { + const reportRequest = deferred(); + let reportSignal: AbortSignal | undefined; + getValidationRunReportMock.mockImplementation((_runId, _format, signal) => { + reportSignal = signal; + return reportRequest.promise; + }); + + const rendered = render(); + fireEvent.click(screen.getByRole('button', { name: 'Download JSON report' })); + await waitFor(() => expect(reportSignal).toBeInstanceOf(AbortSignal)); + + rendered.unmount(); + expect(reportSignal?.aborted).toBe(true); + + await act(async () => reportRequest.resolve(new Blob(['late report']))); + expect(saveValidationRunReportMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ValidationRunReportExports.tsx b/frontend/src/components/ValidationRunReportExports.tsx new file mode 100644 index 0000000..c6bc514 --- /dev/null +++ b/frontend/src/components/ValidationRunReportExports.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react'; +import { getValidationRunReport } from '../api/client'; +import type { ValidationReportFormat } from '../api/contracts'; +import { saveValidationRunReport } from '../downloads/saveValidationRunReport'; +import { ErrorState } from './AsyncState'; + +const FORMAT_LABELS: Record = { + json: 'JSON', + csv: 'CSV', +}; + +type DownloadState = + | { status: 'idle' } + | { status: 'loading'; format: ValidationReportFormat } + | { status: 'success'; format: ValidationReportFormat; fileName: string } + | { status: 'error'; format: ValidationReportFormat; message: string }; + +interface ValidationRunReportExportsProps { + runId: string; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'An unexpected error occurred.'; +} + +export function ValidationRunReportExports({ runId }: ValidationRunReportExportsProps) { + const [download, setDownload] = useState({ status: 'idle' }); + const activeRequest = useRef(null); + const downloadButtons = useRef>>( + {}, + ); + const restoreFocusTo = useRef(null); + + useEffect( + () => () => { + activeRequest.current?.abort(); + activeRequest.current = null; + }, + [], + ); + + useEffect(() => { + if (download.status !== 'success' && download.status !== 'error') { + return; + } + + const format = restoreFocusTo.current; + if (format === null || format !== download.format) { + return; + } + + downloadButtons.current[format]?.focus(); + restoreFocusTo.current = null; + }, [download]); + + const startDownload = async (format: ValidationReportFormat, restoreFocus = false) => { + if (activeRequest.current !== null) { + return; + } + + if (restoreFocus) { + restoreFocusTo.current = format; + downloadButtons.current[format]?.focus(); + } + + const controller = new AbortController(); + activeRequest.current = controller; + setDownload({ status: 'loading', format }); + + try { + const blob = await getValidationRunReport(runId, format, controller.signal); + if (controller.signal.aborted) { + return; + } + + const fileName = saveValidationRunReport(blob, runId, format); + setDownload({ status: 'success', format, fileName }); + } catch (error) { + if (!controller.signal.aborted) { + setDownload({ status: 'error', format, message: errorMessage(error) }); + } + } finally { + if (activeRequest.current === controller) { + activeRequest.current = null; + } + } + }; + + const downloading = download.status === 'loading'; + + return ( +
+

Portable results

+

Export report

+

+ Download a read-only snapshot of this persisted Validation Run and its persisted Issues. + Reports are available for Pending, Processing, Completed, and Failed Runs. +

+

+ Pending and Processing reports reflect the current persisted state and may be incomplete. +

+

+ Active Issue filters affect only the table below; exports always include the full persisted + report. +

+ +
+ {(['json', 'csv'] as const).map((format) => ( + + ))} +
+ + {download.status === 'loading' ? ( +
+

Preparing {FORMAT_LABELS[download.format]} report download…

+
+ ) : null} + + {download.status === 'success' ? ( +
+

{download.fileName} download started.

+
+ ) : null} + + {download.status === 'error' ? ( +
+ void startDownload(download.format, true)} + /> +
+ ) : null} +
+ ); +} diff --git a/frontend/src/downloads/saveValidationRunReport.test.ts b/frontend/src/downloads/saveValidationRunReport.test.ts new file mode 100644 index 0000000..c26e108 --- /dev/null +++ b/frontend/src/downloads/saveValidationRunReport.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { validationRunFixture } from '../test/fixtures'; +import { saveValidationRunReport } from './saveValidationRunReport'; + +describe('saveValidationRunReport', () => { + let createObjectUrl: ReturnType; + let revokeObjectUrl: ReturnType; + let clickLink: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:validation-report'); + revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + clickLink = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('starts a download with a deterministic UUID-based filename and queues URL cleanup', () => { + const blob = new Blob(['{"status":"COMPLETED"}'], { type: 'application/json' }); + + const fileName = saveValidationRunReport(blob, validationRunFixture.id, 'json'); + const clickedLink = clickLink.mock.instances[0] as HTMLAnchorElement; + + expect(fileName).toBe(`validation-run-${validationRunFixture.id}-report.json`); + expect(clickedLink).toMatchObject({ + download: fileName, + href: 'blob:validation-report', + hidden: true, + }); + expect(clickedLink?.isConnected).toBe(false); + expect(createObjectUrl).toHaveBeenCalledWith(blob); + expect(revokeObjectUrl).not.toHaveBeenCalled(); + + vi.runOnlyPendingTimers(); + + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:validation-report'); + }); + + it('does not place an unsafe identifier in the filename', () => { + const fileName = saveValidationRunReport(new Blob(['report']), '../../unsafe\0name', 'csv'); + const clickedLink = clickLink.mock.instances[0] as HTMLAnchorElement; + + expect(fileName).toBe('validation-run-report.csv'); + expect(clickedLink?.download).toBe(fileName); + }); + + it('cleans up the temporary link and object URL when clicking fails', () => { + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { + throw new Error('Browser download failed.'); + }); + + expect(() => + saveValidationRunReport(new Blob(['report']), validationRunFixture.id, 'json'), + ).toThrow('Browser download failed.'); + expect(document.querySelector('a[download]')).toBeNull(); + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:validation-report'); + }); +}); diff --git a/frontend/src/downloads/saveValidationRunReport.ts b/frontend/src/downloads/saveValidationRunReport.ts new file mode 100644 index 0000000..273fd1f --- /dev/null +++ b/frontend/src/downloads/saveValidationRunReport.ts @@ -0,0 +1,36 @@ +import type { ValidationReportFormat } from '../api/contracts'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function reportFileName(runId: string, format: ValidationReportFormat): string { + const safeRunId = UUID_PATTERN.test(runId) ? `${runId.toLowerCase()}-` : ''; + return `validation-run-${safeRunId}report.${format}`; +} + +export function saveValidationRunReport( + blob: Blob, + runId: string, + format: ValidationReportFormat, +): string { + const fileName = reportFileName(runId, format); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = objectUrl; + link.download = fileName; + link.hidden = true; + document.body.append(link); + + try { + link.click(); + } catch (error) { + URL.revokeObjectURL(objectUrl); + throw error; + } finally { + link.remove(); + } + + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + + return fileName; +} diff --git a/frontend/src/pages/ValidationRunDetailPage.test.tsx b/frontend/src/pages/ValidationRunDetailPage.test.tsx index ca12c8e..c5be7e6 100644 --- a/frontend/src/pages/ValidationRunDetailPage.test.tsx +++ b/frontend/src/pages/ValidationRunDetailPage.test.tsx @@ -10,6 +10,7 @@ import { ValidationRunDetailPage } from './ValidationRunDetailPage'; vi.mock('../api/client', () => ({ getDatasets: vi.fn(), getValidationIssues: vi.fn(), + getValidationRunReport: vi.fn(), getValidationRun: vi.fn(), })); @@ -194,6 +195,7 @@ describe('ValidationRunDetailPage', () => { expect(within(summary).getByText('12')).toBeInTheDocument(); expect(within(summary).getByText('5')).toBeInTheDocument(); expect(within(summary).getByText('8')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Export report' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/'); }); @@ -217,6 +219,7 @@ describe('ValidationRunDetailPage', () => { expect(screen.getByText('Not finished')).toBeInTheDocument(); expect(screen.queryByText(/background|actively processing/i)).not.toBeInTheDocument(); expect(screen.queryByText('Failure reason')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download JSON report' })).toBeEnabled(); }); it('shows a persisted failure reason only when present', async () => { @@ -236,6 +239,7 @@ describe('ValidationRunDetailPage', () => { expect(await screen.findByText('Failed')).toBeInTheDocument(); expect(screen.getByText('Failure reason')).toBeInTheDocument(); expect(screen.getByText(failedRun.failureReason!)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download CSV report' })).toBeEnabled(); }); it('uses the Dataset UUID when no matching Dataset exists', async () => { @@ -265,6 +269,7 @@ describe('ValidationRunDetailPage', () => { expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument(); expect(screen.getByText(validationRunFixture.datasetId)).toBeInTheDocument(); expect(await screen.findByText(issues[0].message)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download JSON report' })).toBeEnabled(); }); it('renders persisted Issues in API order and safely distinguishes observed values', async () => { @@ -410,6 +415,7 @@ describe('ValidationRunDetailPage', () => { ).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Run summary' })).toBeInTheDocument(); expect(screen.getByText(validationRunFixture.id)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download CSV report' })).toBeEnabled(); fireEvent.click(within(issueAlert).getByRole('button', { name: 'Retry Validation Issues' })); diff --git a/frontend/src/pages/ValidationRunDetailPage.tsx b/frontend/src/pages/ValidationRunDetailPage.tsx index a4599d1..44811d7 100644 --- a/frontend/src/pages/ValidationRunDetailPage.tsx +++ b/frontend/src/pages/ValidationRunDetailPage.tsx @@ -5,6 +5,7 @@ import type { ValidationRun } from '../api/contracts'; import { EmptyState, ErrorState, LoadingState } from '../components/AsyncState'; import { ValidationIssueList } from '../components/ValidationIssueList'; import { ValidationRunMetadata } from '../components/ValidationRunMetadata'; +import { ValidationRunReportExports } from '../components/ValidationRunReportExports'; import { ValidationRunSummary } from '../components/ValidationRunSummary'; import { useAsyncResource } from '../hooks/useAsyncResource'; @@ -80,6 +81,7 @@ function LoadedValidationRun({ validationRun }: { validationRun: ValidationRun } datasetContextStatus={datasets.status} /> +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 90a64ab..df7299b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -146,10 +146,15 @@ main { .run-metadata, .run-summary, +.run-exports, .run-issues { min-width: 0; } +.run-exports { + grid-column: 1 / -1; +} + .metadata-grid, .summary-grid { display: grid; @@ -390,6 +395,31 @@ main { cursor: not-allowed; } +.report-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.report-download-status, +.report-download-error { + margin-top: 1rem; + overflow-wrap: anywhere; +} + +.report-download-status { + padding: 0.875rem 1rem; + color: #225b36; + background: #edf7f0; + border: 1px solid #bfd9c7; + border-radius: 0.5rem; +} + +.report-download-status p { + margin: 0; +} + .visually-hidden { position: absolute; width: 1px; From c8066ca7631bad2b9ad9890d1c0a9a489b89ce00 Mon Sep 17 00:00:00 2001 From: NkToGo Date: Fri, 14 Aug 2026 23:43:23 +0200 Subject: [PATCH 4/5] Milestone 6 slice 4: add structured application logging --- .../ingestion/ValidationRunService.java | 95 ++++++++- .../reporting/ValidationReportController.java | 22 +++ .../src/main/resources/application.properties | 2 + .../ingestion/ValidationRunServiceTests.java | 77 ++++++++ .../ValidationReportControllerTests.java | 187 ++++++++++++++++++ 5 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java diff --git a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java index b57a1d9..8bd7fb2 100644 --- a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java +++ b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java @@ -27,19 +27,21 @@ class ValidationRunService implements ValidationRunAccess, ValidationRunReportAc ValidationRunResponse create(UUID fileId, CreateValidationRunRequest request) { UUID runId = validationRunLifecycleService.createPending(fileId, request.profileId()); + logCreated(runId, fileId, request.profileId()); try { - return validationRunLifecycleService.process(runId); + return recordProcessingResult(validationRunLifecycleService.process(runId)); } catch (ValidationProcessingFailureException failure) { - LOGGER.error( - "Validation processing failed for Validation Run '{}'; attempting recovery.", - failure.runId(), - failure.getCause()); + logExecutionFailed(runId, fileId, request.profileId(), failure.getCause()); try { - return validationRunRecoveryService.recover(failure); + return recordProcessingResult(validationRunRecoveryService.recover(failure)); } catch (RuntimeException recoveryFailure) { + logRecoveryFailed(runId, fileId, request.profileId(), recoveryFailure); recoveryFailure.addSuppressed(failure); throw recoveryFailure; } + } catch (RuntimeException executionFailure) { + logExecutionFailed(runId, fileId, request.profileId(), executionFailure); + throw executionFailure; } } @@ -73,6 +75,87 @@ private ValidationRun requireExisting(UUID runId) { .orElseThrow(() -> new ValidationRunNotFoundException(runId)); } + private ValidationRunResponse recordProcessingResult(ValidationRunResponse response) { + switch (response.status()) { + case COMPLETED -> { + logFinished(response); + } + case FAILED -> { + logProcessingFailed(response); + } + case PENDING, PROCESSING -> { + LOGGER + .atError() + .addKeyValue("event", "validation_run.execution_failed") + .addKeyValue("runId", response.id()) + .addKeyValue("sourceFileId", response.sourceFileId()) + .addKeyValue("profileId", response.profileId()) + .addKeyValue("status", response.status()) + .log("Validation Run processing returned a nonterminal state."); + } + } + return response; + } + + private static void logCreated(UUID runId, UUID fileId, UUID profileId) { + LOGGER + .atInfo() + .addKeyValue("event", "validation_run.created") + .addKeyValue("runId", runId) + .addKeyValue("sourceFileId", fileId) + .addKeyValue("profileId", profileId) + .log("Validation Run created."); + } + + private static void logFinished(ValidationRunResponse response) { + addPersistedRunFields(LOGGER.atInfo().addKeyValue("event", "validation_run.finished"), response) + .log("Validation Run processing completed."); + } + + private static void logProcessingFailed(ValidationRunResponse response) { + addPersistedRunFields( + LOGGER.atWarn().addKeyValue("event", "validation_run.processing_failed"), response) + .log("Validation Run processing produced a persisted failure."); + } + + private static void logExecutionFailed( + UUID runId, UUID fileId, UUID profileId, Throwable failure) { + LOGGER + .atError() + .addKeyValue("event", "validation_run.execution_failed") + .addKeyValue("runId", runId) + .addKeyValue("sourceFileId", fileId) + .addKeyValue("profileId", profileId) + .setCause(failure) + .log("Validation Run execution failed unexpectedly."); + } + + private static void logRecoveryFailed( + UUID runId, UUID fileId, UUID profileId, Throwable failure) { + LOGGER + .atError() + .addKeyValue("event", "validation_run.recovery_failed") + .addKeyValue("runId", runId) + .addKeyValue("sourceFileId", fileId) + .addKeyValue("profileId", profileId) + .setCause(failure) + .log("Validation Run failure recovery failed unexpectedly."); + } + + private static org.slf4j.spi.LoggingEventBuilder addPersistedRunFields( + org.slf4j.spi.LoggingEventBuilder event, ValidationRunResponse response) { + return event + .addKeyValue("runId", response.id()) + .addKeyValue("datasetId", response.datasetId()) + .addKeyValue("sourceFileId", response.sourceFileId()) + .addKeyValue("profileId", response.profileId()) + .addKeyValue("status", response.status()) + .addKeyValue("totalRows", response.totalRows()) + .addKeyValue("validRows", response.validRows()) + .addKeyValue("invalidRows", response.invalidRows()) + .addKeyValue("issueCount", response.issueCount()); + } + private static ValidationRunResponse toResponse(ValidationRun validationRun) { return new ValidationRunResponse( validationRun.getId(), diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java index 4706e14..e3a5fa6 100644 --- a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java @@ -4,6 +4,8 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -15,6 +17,7 @@ @RestController class ValidationReportController { + private static final Logger LOGGER = LoggerFactory.getLogger(ValidationReportController.class); private static final MediaType CSV_MEDIA_TYPE = new MediaType("text", "csv", StandardCharsets.UTF_8); @@ -42,6 +45,7 @@ ResponseEntity getReport(@PathVariable UUID runId, HttpServletRequest request .body(validationReportCsvWriter.write(report)); }; + logGenerated(report, format); return response; } @@ -56,4 +60,22 @@ private ResponseEntity.BodyBuilder downloadResponse( .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .header(HttpHeaders.CACHE_CONTROL, "no-store"); } + + private static void logGenerated(ValidationReport report, ReportFormat format) { + var validationRun = report.validationRun(); + LOGGER + .atInfo() + .addKeyValue("event", "validation_report.generated") + .addKeyValue("runId", validationRun.id()) + .addKeyValue("datasetId", validationRun.datasetId()) + .addKeyValue("sourceFileId", validationRun.sourceFileId()) + .addKeyValue("profileId", validationRun.profileId()) + .addKeyValue("status", validationRun.status()) + .addKeyValue("format", format.queryValue()) + .addKeyValue("totalRows", validationRun.totalRows()) + .addKeyValue("validRows", validationRun.validRows()) + .addKeyValue("invalidRows", validationRun.invalidRows()) + .addKeyValue("issueCount", validationRun.issueCount()) + .log("Validation report generated."); + } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bb85106..3defa05 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -11,5 +11,7 @@ app.ingestion.max-file-size=${SOURCE_FILE_MAX_SIZE:10MB} spring.servlet.multipart.max-file-size=${app.ingestion.max-file-size} spring.servlet.multipart.max-request-size=${SOURCE_FILE_MAX_REQUEST_SIZE:11MB} +logging.structured.format.console=logstash + management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never diff --git a/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java b/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java index 3367de6..92319be 100644 --- a/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java +++ b/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java @@ -7,11 +7,19 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InOrder; +import org.slf4j.LoggerFactory; class ValidationRunServiceTests { @@ -25,6 +33,24 @@ class ValidationRunServiceTests { mock(ValidationRunRepository.class); private final ValidationRunService service = new ValidationRunService(lifecycleService, recoveryService, validationRunRepository); + private final Logger serviceLogger = (Logger) LoggerFactory.getLogger(ValidationRunService.class); + private final ListAppender logAppender = new ListAppender<>(); + private boolean originalAdditive; + + @BeforeEach + void setUp() { + originalAdditive = serviceLogger.isAdditive(); + serviceLogger.setAdditive(false); + logAppender.start(); + serviceLogger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + serviceLogger.detachAppender(logAppender); + serviceLogger.setAdditive(originalAdditive); + logAppender.stop(); + } @Test void createsPendingRunThenProcessesIt() { @@ -44,6 +70,24 @@ void createsPendingRunThenProcessesIt() { calls.verify(lifecycleService).createPending(fileId, profileId); calls.verify(lifecycleService).process(runId); verifyNoInteractions(recoveryService); + assertEvent("validation_run.created", Level.INFO, runId, null); + assertEvent("validation_run.finished", Level.INFO, runId, null); + } + + @Test + void recordsAHandledPersistedProcessingFailure() { + UUID fileId = UUID.randomUUID(); + UUID profileId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + ValidationRunResponse failed = response(runId, fileId, profileId, ValidationRunStatus.FAILED); + when(lifecycleService.createPending(fileId, profileId)).thenReturn(runId); + when(lifecycleService.process(runId)).thenReturn(failed); + + ValidationRunResponse result = + service.create(fileId, new CreateValidationRunRequest(profileId)); + + assertThat(result).isSameAs(failed); + assertEvent("validation_run.processing_failed", Level.WARN, runId, null); } @Test @@ -111,6 +155,8 @@ void recoversOnlyValidationProcessingFailuresAfterProcessingTransactionReturns() calls.verify(lifecycleService).createPending(fileId, profileId); calls.verify(lifecycleService).process(runId); calls.verify(recoveryService).recover(failure); + assertEvent("validation_run.execution_failed", Level.ERROR, runId, cause); + assertEvent("validation_run.processing_failed", Level.WARN, runId, null); } @Test @@ -126,6 +172,7 @@ void doesNotRecoverUnexpectedParserRuntimeFailure() { .isSameAs(failure); verifyNoInteractions(recoveryService); + assertEvent("validation_run.execution_failed", Level.ERROR, runId, failure); } @Test @@ -144,6 +191,36 @@ void propagatesRecoveryFailure() { assertThatThrownBy(() -> service.create(fileId, new CreateValidationRunRequest(profileId))) .isSameAs(recoveryFailure); assertThat(recoveryFailure.getSuppressed()).containsExactly(processingFailure); + assertEvent( + "validation_run.execution_failed", Level.ERROR, runId, processingFailure.getCause()); + assertEvent("validation_run.recovery_failed", Level.ERROR, runId, recoveryFailure); + } + + private void assertEvent(String eventName, Level level, UUID runId, Throwable expectedThrowable) { + List matchingEvents = + logAppender.list.stream() + .filter( + event -> + event.getKeyValuePairs().stream() + .anyMatch(pair -> pair.key.equals("event") && pair.value.equals(eventName))) + .toList(); + + assertThat(matchingEvents).hasSize(1); + ILoggingEvent event = matchingEvents.getFirst(); + assertThat(event.getLevel()).isEqualTo(level); + assertThat(event.getKeyValuePairs()) + .anySatisfy( + pair -> { + assertThat(pair.key).isEqualTo("runId"); + assertThat(pair.value).isEqualTo(runId); + }); + if (expectedThrowable == null) { + assertThat(event.getThrowableProxy()).isNull(); + } else { + assertThat(event.getThrowableProxy()).isNotNull(); + assertThat(event.getThrowableProxy().getClassName()) + .isEqualTo(expectedThrowable.getClass().getName()); + } } private ValidationRunResponse response( diff --git a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java new file mode 100644 index 0000000..882b42a --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java @@ -0,0 +1,187 @@ +package io.github.nktogo.dataquality.reporting; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.github.nktogo.dataquality.dataset.ValidationRuleSeverity; +import io.github.nktogo.dataquality.dataset.ValidationRuleType; +import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; +import io.github.nktogo.dataquality.ingestion.ValidationRunStatus; +import io.github.nktogo.dataquality.validation.ValidationIssueResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +class ValidationReportControllerTests { + + private static final String SENSITIVE_OBSERVED_VALUE = "=DO_NOT_LOG(\"raw value\")"; + + private final ValidationReportService validationReportService = + mock(ValidationReportService.class); + private final ValidationReportCsvWriter validationReportCsvWriter = + mock(ValidationReportCsvWriter.class); + private final ValidationReportController controller = + new ValidationReportController(validationReportService, validationReportCsvWriter); + private final Logger controllerLogger = + (Logger) LoggerFactory.getLogger(ValidationReportController.class); + private final ListAppender logAppender = new ListAppender<>(); + private boolean originalAdditive; + + @BeforeEach + void setUp() { + originalAdditive = controllerLogger.isAdditive(); + controllerLogger.setAdditive(false); + logAppender.start(); + controllerLogger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + controllerLogger.detachAppender(logAppender); + controllerLogger.setAdditive(originalAdditive); + logAppender.stop(); + } + + @Test + void generatesJsonWithADataSafeStructuredEvent() { + ValidationReport report = report(); + UUID runId = report.validationRun().id(); + when(validationReportService.getReport(runId)).thenReturn(report); + + var response = controller.getReport(runId, requestWithFormat("json")); + + assertThat(response.getBody()).isSameAs(report); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(response.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-store"); + assertThat(response.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("validation-run-" + runId + "-report.json"); + verifyNoInteractions(validationReportCsvWriter); + + ILoggingEvent event = generatedEvent(); + assertThat(event.getLevel()).isEqualTo(Level.INFO); + assertThat(event.getThrowableProxy()).isNull(); + assertThat(event.getKeyValuePairs()) + .anySatisfy( + pair -> { + assertThat(pair.key).isEqualTo("runId"); + assertThat(pair.value).isEqualTo(runId); + }) + .anySatisfy( + pair -> { + assertThat(pair.key).isEqualTo("format"); + assertThat(pair.value).isEqualTo("json"); + }); + assertThat(event.getFormattedMessage()).doesNotContain(SENSITIVE_OBSERVED_VALUE); + assertThat(event.getKeyValuePairs().toString()).doesNotContain(SENSITIVE_OBSERVED_VALUE); + } + + @Test + void generatesCsvWithADataSafeStructuredEvent() { + ValidationReport report = report(); + UUID runId = report.validationRun().id(); + byte[] csv = "header\r\nvalue\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(validationReportService.getReport(runId)).thenReturn(report); + when(validationReportCsvWriter.write(report)).thenReturn(csv); + + var response = controller.getReport(runId, requestWithFormat("csv")); + + assertThat(response.getBody()).isSameAs(csv); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("text/csv;charset=UTF-8")); + assertThat(generatedEvent().getKeyValuePairs()) + .anySatisfy( + pair -> { + assertThat(pair.key).isEqualTo("format"); + assertThat(pair.value).isEqualTo("csv"); + }); + } + + @Test + void doesNotClaimAReportWhenTheSnapshotCannotBeRead() { + ValidationReport report = report(); + UUID runId = report.validationRun().id(); + IllegalStateException failure = new IllegalStateException("Database unavailable."); + when(validationReportService.getReport(runId)).thenThrow(failure); + + assertThatThrownBy(() -> controller.getReport(runId, requestWithFormat("json"))) + .isSameAs(failure); + + assertThat(logAppender.list).noneMatch(this::isGeneratedEvent); + } + + @Test + void doesNotClaimAReportWhenCsvRenderingFails() { + ValidationReport report = report(); + UUID runId = report.validationRun().id(); + IllegalStateException failure = new IllegalStateException("CSV rendering failed."); + when(validationReportService.getReport(runId)).thenReturn(report); + when(validationReportCsvWriter.write(report)).thenThrow(failure); + + assertThatThrownBy(() -> controller.getReport(runId, requestWithFormat("csv"))) + .isSameAs(failure); + + assertThat(logAppender.list).noneMatch(this::isGeneratedEvent); + } + + private HttpServletRequest requestWithFormat(String format) { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameterValues("format")).thenReturn(new String[] {format}); + return request; + } + + private ILoggingEvent generatedEvent() { + List matchingEvents = + logAppender.list.stream().filter(this::isGeneratedEvent).toList(); + assertThat(matchingEvents).hasSize(1); + return matchingEvents.getFirst(); + } + + private boolean isGeneratedEvent(ILoggingEvent event) { + return event.getKeyValuePairs().stream() + .anyMatch( + pair -> pair.key.equals("event") && pair.value.equals("validation_report.generated")); + } + + private ValidationReport report() { + UUID runId = UUID.randomUUID(); + ValidationRunResponse run = + new ValidationRunResponse( + runId, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + ValidationRunStatus.COMPLETED, + 3, + 1, + 2, + 1, + Instant.parse("2026-08-11T12:00:00.123456Z"), + Instant.parse("2026-08-11T12:00:01.123456Z"), + null); + ValidationIssueResponse issue = + new ValidationIssueResponse( + UUID.randomUUID(), + runId, + 2, + "email", + ValidationRuleType.REQUIRED_FIELD, + ValidationRuleSeverity.ERROR, + "Value is required.", + SENSITIVE_OBSERVED_VALUE); + return new ValidationReport(run, List.of(issue)); + } +} From ae311d80a64c09d720ed5e6d4e989ea88070b7b4 Mon Sep 17 00:00:00 2001 From: NkToGo Date: Mon, 17 Aug 2026 23:07:30 +0200 Subject: [PATCH 5/5] Milestone 6 slice 5-6: add operations metrics and finalize MVP --- README.md | 191 +++++++++++++++++- .../ingestion/ValidationRunService.java | 29 ++- .../operations/OperationsMetrics.java | 178 ++++++++++++++++ .../reporting/ValidationReportController.java | 46 ++++- .../src/main/resources/application.properties | 3 +- .../ingestion/ValidationRunServiceTests.java | 22 +- .../OperationsIntegrationTests.java | 121 +++++++++++ .../operations/OperationsMetricsTests.java | 147 ++++++++++++++ .../ValidationReportControllerTests.java | 36 +++- 9 files changed, 743 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/io/github/nktogo/dataquality/operations/OperationsMetrics.java create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsIntegrationTests.java create mode 100644 backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsMetricsTests.java diff --git a/README.md b/README.md index 8693020..e7c921b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Data Quality Platform is a learning and software-engineering project for bui ## Current status -Milestones 1 through 4 are complete. Milestone 5 now has a read-only frontend Dashboard and addressable Validation Run detail experience implemented; it remains in progress pending final review, CI, manual end-to-end verification, and merge: +Milestones 1 through 5 are complete. Milestone 6 reporting and operations are implemented on the current branch and remain in progress pending final automated verification, CI, manual end-to-end review, and merge: - a Java 21 and Spring Boot backend - a React and TypeScript frontend @@ -26,14 +26,18 @@ Milestones 1 through 4 are complete. Milestone 5 now has a read-only frontend Da - a SourceFile-nested endpoint that creates, parses, validates, and completes a Validation Run - global Validation Run list and detail REST endpoints - a read-only endpoint that lists a Validation Run's persisted Issues +- read-only JSON and CSV Validation Report export endpoints +- managed JSON and CSV report downloads from the Validation Run detail page +- Logstash-compatible structured JSON application logs +- Actuator health and runtime metrics endpoints with bounded domain metrics - deterministic UTF-8 CSV parsing with persisted parser and validation outcomes - PostgreSQL Testcontainers repository and API integration tests - backend and frontend tests and formatting checks - a GitHub Actions workflow for repository checks -The backend connects to PostgreSQL at startup. Flyway is the sole schema owner and applies migrations V1 through V9 for Dataset, Validation Profile, Validation Rule, rule-specific parameter constraints, SourceFile, Validation Run, Validation Run lifecycle and completed-summary constraints, and Validation Issue persistence. Hibernate validates the JPA mappings with `spring.jpa.hibernate.ddl-auto=validate` and does not generate schema changes. The backend exposes the Actuator health endpoint and the Dataset, Validation Profile, Validation Rule, SourceFile upload, Validation Run, and Validation Issue retrieval endpoints documented below. The frontend provides the read-only Milestone 5 Dashboard documented below. +The backend connects to PostgreSQL at startup. Flyway is the sole schema owner and applies migrations V1 through V9 for Dataset, Validation Profile, Validation Rule, rule-specific parameter constraints, SourceFile, Validation Run, Validation Run lifecycle and completed-summary constraints, and Validation Issue persistence. Hibernate validates the JPA mappings with `spring.jpa.hibernate.ddl-auto=validate` and does not generate schema changes. The backend exposes Actuator health and metrics plus the Dataset, Validation Profile, Validation Rule, SourceFile upload, Validation Run, Validation Issue retrieval, and Validation Report endpoints documented below. The frontend provides the read-only Dashboard, Run detail, Issue filtering, and report-download experience documented below. -Dataset metadata can be created, listed, and retrieved. Validation Profiles can be created and listed for an existing Dataset. Validation Rules can be created and listed for an existing Validation Profile, and every Rule's parameters are validated against its type-specific contract before persistence. CSV files can be uploaded for an existing Dataset, and the backend stores their metadata, exact bytes, and SHA-256 checksums. Creating a Validation Run for a SourceFile and a Validation Profile from the same Dataset reads the private stored bytes, parses them, loads the Profile's enabled Rules, and validates the parsed rows synchronously. Successful validation atomically persists generated Issues and the `totalRows`, `validRows`, `invalidRows`, and `issueCount` summary before completing the Run. Expected parser failures, missing required headers, and recovered validation-processing failures persist safe `FAILED` outcomes without partial Issues. Unexpected SourceFile-access or parser runtime failures retain the separately committed `PENDING` Run for diagnosis. Validation Runs can be listed globally and retrieved by ID, and their persisted Issues can be retrieved through a read-only endpoint. The frontend filters retrieved Issues client-side; the backend collection remains unfiltered and unpaged. Report and export functionality remains deferred to Milestone 6 or later. Dataset, profile, rule, SourceFile, and Validation Run updates or deletion, profile, rule, and SourceFile detail retrieval, pagination, authentication, and AI features are also not implemented yet. +Dataset metadata can be created, listed, and retrieved. Validation Profiles can be created and listed for an existing Dataset. Validation Rules can be created and listed for an existing Validation Profile, and every Rule's parameters are validated against its type-specific contract before persistence. CSV files can be uploaded for an existing Dataset, and the backend stores their metadata, exact bytes, and SHA-256 checksums. Creating a Validation Run for a SourceFile and a Validation Profile from the same Dataset reads the private stored bytes, parses them, loads the Profile's enabled Rules, and validates the parsed rows synchronously. Successful validation atomically persists generated Issues and the `totalRows`, `validRows`, `invalidRows`, and `issueCount` summary before completing the Run. Expected parser failures, missing required headers, and recovered validation-processing failures persist safe `FAILED` outcomes without partial Issues. Unexpected SourceFile-access or parser runtime failures retain the separately committed `PENDING` Run for diagnosis. Validation Runs can be listed globally and retrieved by ID, their persisted Issues can be retrieved, and a faithful persisted snapshot can be exported as JSON or CSV. The frontend filters retrieved Issues client-side; filters do not alter reports. Dataset, profile, rule, SourceFile, and Validation Run updates or deletion, profile, rule, and SourceFile detail retrieval, pagination, authentication, and AI features are not implemented. ## Repository layout @@ -46,6 +50,18 @@ compose.yaml Local PostgreSQL service PROJECT_BRIEF.md Product scope and milestone definition ``` +## Architecture + +The project is a modular monolith. The React and TypeScript browser application uses relative `/api` requests; Vite proxies those requests to the Spring Boot backend during local development. The backend is one deployable application whose packages separate responsibilities without exposing JPA entities or repositories across module boundaries: + +- `dataset` owns Datasets, Validation Profiles, Validation Rules, and their public read boundaries. +- `ingestion` owns SourceFiles, CSV parsing, Validation Runs, and Run lifecycle persistence. +- `validation` owns deterministic Rule execution and persisted Validation Issues. +- `reporting` reads immutable Run and Issue views and renders on-demand JSON and CSV snapshots. It never recalculates summaries or changes processing state. +- `operations` owns bounded application metrics; Spring Boot Actuator provides health and runtime diagnostics, and application events are written as structured JSON. + +PostgreSQL is the system of record. Flyway alone manages its schema, while Hibernate validates mappings at startup. Docker Compose intentionally provides PostgreSQL only; the backend and Vite development server run as local processes. No message broker, background worker, or monitoring stack is part of the MVP. + ## Prerequisites - Java Development Kit 21 @@ -122,7 +138,7 @@ Get-Content ..\.env | .\mvnw.cmd spring-boot:run ``` -The backend listens on `http://localhost:8080`. Its health endpoint is `http://localhost:8080/actuator/health`. A missing or incorrect database password causes startup to fail when Flyway connects. +The backend listens on `http://localhost:8080`. Its health endpoint is `http://localhost:8080/actuator/health`, and runtime meter discovery is available at `http://localhost:8080/actuator/metrics`. A missing or incorrect database password causes startup to fail when Flyway connects. ## Read-only frontend Dashboard @@ -132,11 +148,13 @@ Dataset names are resolved from the Dataset collection when possible. The Datase The Validation Run detail route presents persisted metadata, lifecycle timestamps, any failure reason, the four persisted summary counters, and the Run's persisted Validation Issues. Issue rows show the logical row number, exact field name, Rule type, severity text, message, and observed value. Markup-like observed values render literally as text. Non-empty whitespace is preserved, while null and empty strings have distinct labels. Entering or refreshing a `/runs/:runId` URL works with the Vite development server. +The same detail route provides managed “Download JSON report” and “Download CSV report” actions. A download has its own loading, success, error, and format-specific Retry state without hiding the Run or Issues. Only one report request is active at a time, and leaving the route aborts it. Reports always contain the complete persisted snapshot; active Issue filters do not alter export requests. `PENDING` and `PROCESSING` reports are allowed but are explicitly described as potentially incomplete current snapshots. + Issue filtering happens entirely in the browser after the collection is retrieved. Severity filtering matches `ERROR` or `WARNING`. Field-name filtering uses exact, case-sensitive, and whitespace-sensitive equality. When both filters are active, an Issue must match both conditions. Filtering preserves the backend collection order and sends no filter parameters to the API. The Dashboard has independent loading, error, Retry, and empty states for Datasets and Validation Runs. Validation Run loading and Issue loading have their own error and Retry states. An existing Run without persisted Issues has a dedicated empty state. Active filters with no matches show a distinct empty-filter-result state and a Clear filters control. Retry controls repeat the relevant read-only GET request; they do not retry backend processing. -Milestone 5 remains read-only. It has no UI for creating, uploading, updating, deleting, starting, retrying processing, or cancelling resources. The existing REST API and commands below can create representative local data. The current MVP collections are unpaged and provide no pagination, server-side filtering, or server-side sorting. Report and export functionality remains deferred to Milestone 6 or later. +The frontend remains read-only: report downloads use GET requests and do not change backend state. It has no UI for creating, uploading, updating, deleting, starting, retrying processing, or cancelling resources. The existing REST API and commands below can create representative local data. The current MVP collections and reports are unpaged and provide no pagination, server-side filtering, or server-side sorting. ### Run the frontend locally @@ -530,6 +548,7 @@ Available endpoints: - `POST /api/files/{fileId}/validation-runs`: create a Validation Run and synchronously parse and validate its SourceFile - `GET /api/validation-runs`: list all persisted Validation Runs - `GET /api/validation-runs/{runId}`: retrieve one Validation Run +- `GET /api/validation-runs/{runId}/report?format=json|csv`: export a persisted report snapshot The request must use `application/json` and contain only the Validation Profile UUID: @@ -667,6 +686,14 @@ curl --fail-with-body \ curl --fail-with-body \ "http://localhost:8080/api/validation-runs/$RUN_ID/issues" + +curl --fail-with-body \ + --output "validation-run-$RUN_ID-report.json" \ + "http://localhost:8080/api/validation-runs/$RUN_ID/report?format=json" + +curl --fail-with-body \ + --output "validation-run-$RUN_ID-report.csv" \ + "http://localhost:8080/api/validation-runs/$RUN_ID/report?format=csv" ``` Windows PowerShell, continuing from the SourceFile and Validation Profile examples: @@ -686,6 +713,14 @@ $runs Invoke-RestMethod "http://localhost:8080/api/validation-runs/$($run.id)" Invoke-RestMethod "http://localhost:8080/api/validation-runs/$($run.id)/issues" + +Invoke-WebRequest ` + -Uri "http://localhost:8080/api/validation-runs/$($run.id)/report?format=json" ` + -OutFile "validation-run-$($run.id)-report.json" + +Invoke-WebRequest ` + -Uri "http://localhost:8080/api/validation-runs/$($run.id)/report?format=csv" ` + -OutFile "validation-run-$($run.id)-report.csv" ``` No Validation Run update, deletion, retry, or separate summary endpoint is implemented. Run creation performs the synchronous parse-and-validate workflow and returns the completed summary through the existing Run representation. @@ -723,12 +758,122 @@ Issues are ordered by `rowNumber` ascending, `fieldName` ascending, `ruleType` a A valid but unknown Run UUID returns the existing `Validation Run not found` `404 Not Found` Problem Details response with the Issue collection path as its `instance`. A malformed Run UUID returns `400 Bad Request`. -Issue retrieval is read-only. It does not parse CSV, invoke validation, create or delete Issues, calculate summaries, compare the result with `issueCount`, or change the Run lifecycle. The endpoint itself provides no filtering, pagination, user-selectable sorting, or public Issue write operation. The Milestone 5 frontend filters the retrieved collection client-side by severity and exact field name; it does not send filter parameters to the backend. Report and export functionality remains deferred to Milestone 6 or later. +Issue retrieval is read-only. It does not parse CSV, invoke validation, create or delete Issues, calculate summaries, compare the result with `issueCount`, or change the Run lifecycle. The endpoint itself provides no filtering, pagination, user-selectable sorting, or public Issue write operation. The frontend filters the retrieved collection client-side by severity and exact field name; it does not send filter parameters to the backend or reporting endpoint. + +## Validation Report API + +A Validation Report is generated on demand from one persisted Validation Run and its complete ordered Issue collection. Reports are not stored. Generation uses a repeatable-read, read-only transaction and never reads SourceFile bytes, parses CSV, invokes Rules, recalculates counters, compares `issueCount`, retries processing, or changes database state. + +Available endpoint: + +- `GET /api/validation-runs/{runId}/report?format=json` +- `GET /api/validation-runs/{runId}/report?format=csv` + +Exactly one lowercase `format` query value is required. Missing, blank, repeated, case-varied, or unsupported values return `400 Bad Request` with `application/problem+json`: + +```json +{ + "title": "Invalid report format", + "status": 400, + "detail": "Query parameter 'format' must be exactly one of: json, csv.", + "instance": "/api/validation-runs/1d97a9a7-eb56-44da-a566-a9630f23cbcb/report" +} +``` + +An existing `PENDING`, `PROCESSING`, `COMPLETED`, or `FAILED` Run can be exported. A nonterminal report is only the persisted snapshot visible when its transaction begins and may be incomplete. Successful responses use `Cache-Control: no-store` and an attachment filename of `validation-run-{runId}-report.json` or `validation-run-{runId}-report.csv`. A valid unknown Run uses the existing `Validation Run not found` response with the report path as its `instance`; a malformed Run UUID returns `400 Bad Request`. + +### JSON report + +JSON reports use `application/json` and contain two top-level fields. `validationRun` is the existing exact 12-field Run representation, and `issues` contains the existing exact eight-field Issue representations in persisted retrieval order: + +```json +{ + "validationRun": { + "id": "1d97a9a7-eb56-44da-a566-a9630f23cbcb", + "datasetId": "47d9bea4-1130-4b9b-8fb3-ea23893d51e5", + "sourceFileId": "54985ec5-103b-4d2b-95f3-0b57e2d74336", + "profileId": "6dc81327-2a6b-46c9-9a09-43a64f989ac2", + "status": "COMPLETED", + "totalRows": 2, + "validRows": 1, + "invalidRows": 1, + "issueCount": 1, + "startedAt": "2026-07-26T12:34:56.123456Z", + "finishedAt": "2026-07-26T12:34:56.234567Z", + "failureReason": null + }, + "issues": [ + { + "id": "9f14aeba-fec4-476a-8e2d-216871f44b42", + "runId": "1d97a9a7-eb56-44da-a566-a9630f23cbcb", + "rowNumber": 2, + "fieldName": "email", + "ruleType": "REQUIRED_FIELD", + "severity": "ERROR", + "message": "Value is required.", + "observedValue": "" + } + ] +} +``` + +The report contains no generated timestamp or resolved Dataset/Profile/SourceFile names. It represents only stored Run and Issue fields, so repeated exports of unchanged data are semantically deterministic. + +### CSV report + +CSV reports use `text/csv;charset=UTF-8`, UTF-8 without a byte-order mark, RFC 4180 quoting, and CRLF record separators. The header is fixed: + +```text +validation_run_id,dataset_id,source_file_id,profile_id,status,total_rows,valid_rows,invalid_rows,issue_count,started_at,finished_at,failure_reason,issue_present,issue_id,issue_run_id,row_number,field_name,rule_type,severity,message,observed_value_present,observed_value +``` + +Each persisted Issue produces one row with the Run fields repeated. A Run without Issues produces one Run-only row with `issue_present=false` and empty Issue fields. For an Issue, `observed_value_present=false` with an empty `observed_value` represents `null`; `observed_value_present=true` with an empty `observed_value` represents the persisted empty string. Whitespace-only values, Unicode, commas, quotes, embedded line breaks, markup-like text, and formula-leading characters are preserved exactly. + +CSV fields are data, not trusted spreadsheet instructions. Values beginning with `=`, `+`, `-`, or `@` are deliberately not rewritten, so review untrusted exports before opening them in software that evaluates formulas. ## Persistence relationships Validation Profiles and SourceFiles require an existing Dataset, Validation Rules require an existing Validation Profile, Validation Runs require an existing Dataset, SourceFile, and Validation Profile, and Validation Issues require an existing Validation Run. Validation Run creation also requires the SourceFile and Validation Profile to belong to the same Dataset. All foreign keys use `ON DELETE RESTRICT`, and no cascading deletion is configured. If rows are removed directly during local cleanup, delete Validation Issues first, then Validation Runs, then Validation Rules and SourceFiles, then Validation Profiles, and finally Datasets. +## Operations + +The backend writes application logs to standard output as one Logstash-compatible JSON object per application log event. Spring Boot framework logs use the same structured console formatter. The deliberately small domain event vocabulary is: + +- `validation_run.created` +- `validation_run.finished` +- `validation_run.processing_failed` +- `validation_run.execution_failed` +- `validation_run.recovery_failed` +- `validation_report.generated` + +Events use structured fields for relevant resource UUIDs, Run status, report format, and persisted counters. Logs do not include SourceFile bytes, observed values, Rule parameters, request bodies, database credentials, Dataset descriptions, or uploaded filenames. Unexpected internal failures retain their exception diagnostics. Spring Boot's standard `LOGGING_STRUCTURED_FORMAT_CONSOLE` environment variable can override the default `logstash` console format when diagnosing a local problem. + +Exactly two Actuator endpoints are exposed over HTTP: + +- `GET /actuator/health`: application health with component details hidden +- `GET /actuator/metrics`: names of collected runtime and application meters + +The metrics endpoint includes built-in JVM, process, system, HTTP, datasource, and logging metrics plus these low-cardinality application meters: + +- `dataquality.validation.runs.created` +- `dataquality.validation.processing.duration`, tagged only by `outcome=completed|failed|error` +- `dataquality.reports.generated`, tagged only by `format=json|csv` +- `dataquality.report.generation.duration`, tagged only by fixed `format` and `outcome=success|error` + +Inspect health and metrics locally: + +```sh +curl --fail-with-body http://localhost:8080/actuator/health +curl --fail-with-body http://localhost:8080/actuator/metrics +curl --fail-with-body http://localhost:8080/actuator/metrics/jvm.memory.used +curl --fail-with-body http://localhost:8080/actuator/metrics/dataquality.validation.runs.created +curl --fail-with-body http://localhost:8080/actuator/metrics/dataquality.validation.processing.duration +curl --fail-with-body http://localhost:8080/actuator/metrics/dataquality.reports.generated +curl --fail-with-body http://localhost:8080/actuator/metrics/dataquality.report.generation.duration +``` + +Micrometer's in-memory registry is diagnostic and resets whenever the backend restarts. Prometheus, Grafana, and external metric or log shipping are not part of the MVP. The application has no authentication, so do not expose the backend or its Actuator port to an untrusted network. Sensitive Actuator endpoints such as `env`, `configprops`, `loggers`, `heapdump`, and `threaddump` remain unexposed. + ## Backend commands Run these commands from `backend/`. Replace `./mvnw` with `.\mvnw.cmd` on Windows. @@ -788,12 +933,42 @@ The GitHub Actions workflow runs three independent jobs on pushes and pull reque - frontend install, lint, formatting, test, and build checks on Node.js 24 - Docker Compose configuration validation +## Known limitations + +- Validation Run creation, CSV parsing, Rule execution, Issue persistence, and report generation are synchronous and in-memory. +- Collections and reports are unpaged. The managed frontend download buffers the complete report in browser memory before starting the save operation. +- Issue filtering is client-side only; there is no server-side filtering, sorting, or report filtering. +- Reports are current persisted snapshots. A `PENDING` or `PROCESSING` report may be incomplete and can differ from a later request. +- CSV preserves formula-leading text exactly and does not neutralize spreadsheet formulas. +- The application has no authentication, authorization, multi-tenancy, update/delete operations, cancellation, or processing-retry action. +- The frontend has no creation or upload UI and no SourceFile or Validation Profile detail endpoint to resolve those UUIDs. +- Docker Compose provides PostgreSQL only. There is no containerized full application, production deployment, background worker, message broker, Prometheus, or Grafana configuration. +- Vite's `/api` proxy is a development convenience, not a production routing configuration. +- OpenAPI was listed as an initial technology choice but is not implemented in the MVP. This README is the canonical implemented API contract. +- No AI feature is implemented; any later AI assistance must remain advisory and must not modify data automatically. + +## Final MVP review + +Do not treat Milestone 6 or the MVP as complete until this review succeeds against the real local stack: + +1. Start PostgreSQL through Compose, load `.env`, start the backend, and start Vite with the commands above. +2. Use the documented REST examples to create a Dataset, Validation Profile, all five Rule types, a SourceFile, and a Validation Run. +3. Verify successful and failed Run lifecycle behavior, persisted summary counters, exact Issue order, and the Dashboard/detail/filter states. +4. Download JSON and CSV from the Run detail page and both report API forms. Compare Run fields, counters, Issues, null, empty, whitespace-only, Unicode, markup-like, embedded CSV syntax, and formula-leading values. +5. Verify a Run without Issues, a failed Run, and the documented nonterminal snapshot behavior covered by integration tests. +6. Confirm active frontend filters do not alter report requests or report content. Exercise keyboard operation, announcements, narrow widths, report failure, and Retry by stopping and restarting the backend. +7. Parse representative application log lines as JSON, confirm the expected event fields, and check that CSV contents and observed values are absent. +8. Query health, built-in runtime meters, and the four application meters before and after Run/report operations. Confirm unexposed Actuator endpoints remain unavailable. +9. Run the complete backend, frontend, audit, and Compose verification commands and require green GitHub Actions. +10. Review all 14 acceptance criteria in `PROJECT_BRIEF.md`, review the limitations above, and complete project review before merging. + ## Milestone status +- Milestone 1: complete, with repository foundations, Spring Boot and React applications, PostgreSQL Compose, CI, and health support - Milestone 2: complete, with PostgreSQL persistence and Dataset, Validation Profile, and Validation Rule REST vertical slices - Milestone 3: complete, with SourceFile upload, exact byte storage and SHA-256 checksums, synchronous CSV parsing, persisted `PROCESSING` and parser-failure lifecycle states, and Validation Run retrieval - Milestone 4: complete, with deterministic rule-specific parameter validation, synchronous Rule execution, Validation Issue persistence and retrieval, validation-derived Run summaries, and successful transition to `COMPLETED` -- Milestone 5: in progress, with the read-only Dashboard, addressable Run detail, persisted summaries and Issues, client-side filters, and user-visible async and empty states implemented and awaiting final manual verification and review -- Milestone 6: report export, structured logs, runtime metrics, and final documentation +- Milestone 5: complete, with the read-only Dashboard, addressable Run detail, persisted summaries and Issues, client-side filters, and user-visible async and empty states +- Milestone 6: implemented on the current branch with JSON/CSV report export, managed downloads, structured logs, runtime metrics, and final documentation; completion still requires final automated verification, CI, manual end-to-end review, and merge The detailed product scope and milestone definitions are maintained in `PROJECT_BRIEF.md`. diff --git a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java index 8bd7fb2..05c15ff 100644 --- a/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java +++ b/backend/src/main/java/io/github/nktogo/dataquality/ingestion/ValidationRunService.java @@ -1,5 +1,8 @@ package io.github.nktogo.dataquality.ingestion; +import io.github.nktogo.dataquality.operations.OperationsMetrics; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ValidationProcessingOutcome; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ValidationProcessingSample; import java.util.List; import java.util.UUID; import org.slf4j.Logger; @@ -15,31 +18,42 @@ class ValidationRunService implements ValidationRunAccess, ValidationRunReportAc private final ValidationRunLifecycleService validationRunLifecycleService; private final ValidationRunRecoveryService validationRunRecoveryService; private final ValidationRunRepository validationRunRepository; + private final OperationsMetrics operationsMetrics; ValidationRunService( ValidationRunLifecycleService validationRunLifecycleService, ValidationRunRecoveryService validationRunRecoveryService, - ValidationRunRepository validationRunRepository) { + ValidationRunRepository validationRunRepository, + OperationsMetrics operationsMetrics) { this.validationRunLifecycleService = validationRunLifecycleService; this.validationRunRecoveryService = validationRunRecoveryService; this.validationRunRepository = validationRunRepository; + this.operationsMetrics = operationsMetrics; } ValidationRunResponse create(UUID fileId, CreateValidationRunRequest request) { UUID runId = validationRunLifecycleService.createPending(fileId, request.profileId()); + operationsMetrics.incrementValidationRunsCreated(); logCreated(runId, fileId, request.profileId()); + ValidationProcessingSample processingSample = operationsMetrics.startValidationProcessing(); + try { - return recordProcessingResult(validationRunLifecycleService.process(runId)); + return recordProcessingResult(validationRunLifecycleService.process(runId), processingSample); } catch (ValidationProcessingFailureException failure) { logExecutionFailed(runId, fileId, request.profileId(), failure.getCause()); try { - return recordProcessingResult(validationRunRecoveryService.recover(failure)); + return recordProcessingResult( + validationRunRecoveryService.recover(failure), processingSample); } catch (RuntimeException recoveryFailure) { + operationsMetrics.recordValidationProcessing( + processingSample, ValidationProcessingOutcome.ERROR); logRecoveryFailed(runId, fileId, request.profileId(), recoveryFailure); recoveryFailure.addSuppressed(failure); throw recoveryFailure; } } catch (RuntimeException executionFailure) { + operationsMetrics.recordValidationProcessing( + processingSample, ValidationProcessingOutcome.ERROR); logExecutionFailed(runId, fileId, request.profileId(), executionFailure); throw executionFailure; } @@ -75,15 +89,22 @@ private ValidationRun requireExisting(UUID runId) { .orElseThrow(() -> new ValidationRunNotFoundException(runId)); } - private ValidationRunResponse recordProcessingResult(ValidationRunResponse response) { + private ValidationRunResponse recordProcessingResult( + ValidationRunResponse response, ValidationProcessingSample processingSample) { switch (response.status()) { case COMPLETED -> { + operationsMetrics.recordValidationProcessing( + processingSample, ValidationProcessingOutcome.COMPLETED); logFinished(response); } case FAILED -> { + operationsMetrics.recordValidationProcessing( + processingSample, ValidationProcessingOutcome.FAILED); logProcessingFailed(response); } case PENDING, PROCESSING -> { + operationsMetrics.recordValidationProcessing( + processingSample, ValidationProcessingOutcome.ERROR); LOGGER .atError() .addKeyValue("event", "validation_run.execution_failed") diff --git a/backend/src/main/java/io/github/nktogo/dataquality/operations/OperationsMetrics.java b/backend/src/main/java/io/github/nktogo/dataquality/operations/OperationsMetrics.java new file mode 100644 index 0000000..bb2e73f --- /dev/null +++ b/backend/src/main/java/io/github/nktogo/dataquality/operations/OperationsMetrics.java @@ -0,0 +1,178 @@ +package io.github.nktogo.dataquality.operations; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import org.springframework.stereotype.Component; + +@Component +public final class OperationsMetrics { + + private static final String VALIDATION_RUNS_CREATED = "dataquality.validation.runs.created"; + private static final String VALIDATION_PROCESSING_DURATION = + "dataquality.validation.processing.duration"; + private static final String REPORTS_GENERATED = "dataquality.reports.generated"; + private static final String REPORT_GENERATION_DURATION = "dataquality.report.generation.duration"; + + private final Counter validationRunsCreated; + private final MeterRegistry meterRegistry; + private final Map validationProcessingTimers; + private final Map reportsGenerated; + private final Map> reportGenerationTimers; + + OperationsMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = Objects.requireNonNull(meterRegistry, "meterRegistry must not be null"); + this.validationRunsCreated = + Counter.builder(VALIDATION_RUNS_CREATED) + .description("Number of Validation Runs created") + .register(meterRegistry); + this.validationProcessingTimers = registerValidationProcessingTimers(meterRegistry); + this.reportsGenerated = registerReportCounters(meterRegistry); + this.reportGenerationTimers = registerReportGenerationTimers(meterRegistry); + } + + public void incrementValidationRunsCreated() { + validationRunsCreated.increment(); + } + + public ValidationProcessingSample startValidationProcessing() { + return new ValidationProcessingSample(Timer.start(meterRegistry)); + } + + public void recordValidationProcessing( + ValidationProcessingSample sample, ValidationProcessingOutcome outcome) { + Objects.requireNonNull(sample, "sample must not be null") + .stop( + validationProcessingTimers.get( + Objects.requireNonNull(outcome, "outcome must not be null"))); + } + + public void incrementReportsGenerated(ReportFormat format) { + reportsGenerated.get(Objects.requireNonNull(format, "format must not be null")).increment(); + } + + public ReportGenerationSample startReportGeneration() { + return new ReportGenerationSample(Timer.start(meterRegistry)); + } + + public void recordReportGeneration( + ReportGenerationSample sample, ReportFormat format, ReportGenerationOutcome outcome) { + Objects.requireNonNull(sample, "sample must not be null") + .stop( + reportGenerationTimers + .get(Objects.requireNonNull(format, "format must not be null")) + .get(Objects.requireNonNull(outcome, "outcome must not be null"))); + } + + private static Map registerValidationProcessingTimers( + MeterRegistry meterRegistry) { + Map timers = + new EnumMap<>(ValidationProcessingOutcome.class); + for (ValidationProcessingOutcome outcome : ValidationProcessingOutcome.values()) { + timers.put( + outcome, + Timer.builder(VALIDATION_PROCESSING_DURATION) + .description("Validation Run processing duration") + .tag("outcome", outcome.tagValue) + .register(meterRegistry)); + } + return Map.copyOf(timers); + } + + private static Map registerReportCounters(MeterRegistry meterRegistry) { + Map counters = new EnumMap<>(ReportFormat.class); + for (ReportFormat format : ReportFormat.values()) { + counters.put( + format, + Counter.builder(REPORTS_GENERATED) + .description("Number of reports generated") + .tag("format", format.tagValue) + .register(meterRegistry)); + } + return Map.copyOf(counters); + } + + private static Map> + registerReportGenerationTimers(MeterRegistry meterRegistry) { + Map> timers = + new EnumMap<>(ReportFormat.class); + for (ReportFormat format : ReportFormat.values()) { + Map formatTimers = + new EnumMap<>(ReportGenerationOutcome.class); + for (ReportGenerationOutcome outcome : ReportGenerationOutcome.values()) { + formatTimers.put( + outcome, + Timer.builder(REPORT_GENERATION_DURATION) + .description("Report generation duration") + .tag("format", format.tagValue) + .tag("outcome", outcome.tagValue) + .register(meterRegistry)); + } + timers.put(format, Map.copyOf(formatTimers)); + } + return Map.copyOf(timers); + } + + public enum ValidationProcessingOutcome { + COMPLETED("completed"), + FAILED("failed"), + ERROR("error"); + + private final String tagValue; + + ValidationProcessingOutcome(String tagValue) { + this.tagValue = tagValue; + } + } + + public enum ReportFormat { + JSON("json"), + CSV("csv"); + + private final String tagValue; + + ReportFormat(String tagValue) { + this.tagValue = tagValue; + } + } + + public enum ReportGenerationOutcome { + SUCCESS("success"), + ERROR("error"); + + private final String tagValue; + + ReportGenerationOutcome(String tagValue) { + this.tagValue = tagValue; + } + } + + public static final class ValidationProcessingSample { + + private final Timer.Sample sample; + + private ValidationProcessingSample(Timer.Sample sample) { + this.sample = sample; + } + + private void stop(Timer timer) { + sample.stop(timer); + } + } + + public static final class ReportGenerationSample { + + private final Timer.Sample sample; + + private ReportGenerationSample(Timer.Sample sample) { + this.sample = sample; + } + + private void stop(Timer timer) { + sample.stop(timer); + } + } +} diff --git a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java index e3a5fa6..8c996e2 100644 --- a/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java +++ b/backend/src/main/java/io/github/nktogo/dataquality/reporting/ValidationReportController.java @@ -1,5 +1,8 @@ package io.github.nktogo.dataquality.reporting; +import io.github.nktogo.dataquality.operations.OperationsMetrics; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportGenerationOutcome; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportGenerationSample; import jakarta.servlet.http.HttpServletRequest; import java.nio.charset.StandardCharsets; import java.util.List; @@ -23,30 +26,44 @@ class ValidationReportController { private final ValidationReportService validationReportService; private final ValidationReportCsvWriter validationReportCsvWriter; + private final OperationsMetrics operationsMetrics; ValidationReportController( ValidationReportService validationReportService, - ValidationReportCsvWriter validationReportCsvWriter) { + ValidationReportCsvWriter validationReportCsvWriter, + OperationsMetrics operationsMetrics) { this.validationReportService = validationReportService; this.validationReportCsvWriter = validationReportCsvWriter; + this.operationsMetrics = operationsMetrics; } @GetMapping("/api/validation-runs/{runId}/report") ResponseEntity getReport(@PathVariable UUID runId, HttpServletRequest request) { String[] formatValues = request.getParameterValues("format"); ReportFormat format = ReportFormat.parse(formatValues == null ? null : List.of(formatValues)); + ReportGenerationSample generationSample = operationsMetrics.startReportGeneration(); - ValidationReport report = validationReportService.getReport(runId); - ResponseEntity response = - switch (format) { - case JSON -> downloadResponse(runId, format, MediaType.APPLICATION_JSON).body(report); - case CSV -> - downloadResponse(runId, format, CSV_MEDIA_TYPE) - .body(validationReportCsvWriter.write(report)); - }; + try { + ValidationReport report = validationReportService.getReport(runId); + ResponseEntity response = + switch (format) { + case JSON -> downloadResponse(runId, format, MediaType.APPLICATION_JSON).body(report); + case CSV -> + downloadResponse(runId, format, CSV_MEDIA_TYPE) + .body(validationReportCsvWriter.write(report)); + }; - logGenerated(report, format); - return response; + OperationsMetrics.ReportFormat metricsFormat = toMetricsFormat(format); + operationsMetrics.incrementReportsGenerated(metricsFormat); + operationsMetrics.recordReportGeneration( + generationSample, metricsFormat, ReportGenerationOutcome.SUCCESS); + logGenerated(report, format); + return response; + } catch (RuntimeException failure) { + operationsMetrics.recordReportGeneration( + generationSample, toMetricsFormat(format), ReportGenerationOutcome.ERROR); + throw failure; + } } private ResponseEntity.BodyBuilder downloadResponse( @@ -61,6 +78,13 @@ private ResponseEntity.BodyBuilder downloadResponse( .header(HttpHeaders.CACHE_CONTROL, "no-store"); } + private static OperationsMetrics.ReportFormat toMetricsFormat(ReportFormat format) { + return switch (format) { + case JSON -> OperationsMetrics.ReportFormat.JSON; + case CSV -> OperationsMetrics.ReportFormat.CSV; + }; + } + private static void logGenerated(ValidationReport report, ReportFormat format) { var validationRun = report.validationRun(); LOGGER diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3defa05..61b48fa 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -13,5 +13,6 @@ spring.servlet.multipart.max-request-size=${SOURCE_FILE_MAX_REQUEST_SIZE:11MB} logging.structured.format.console=logstash -management.endpoints.web.exposure.include=health +management.endpoints.web.exposure.include=health,metrics management.endpoint.health.show-details=never +management.metrics.tags.application=${spring.application.name} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java b/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java index 92319be..4167897 100644 --- a/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java +++ b/backend/src/test/java/io/github/nktogo/dataquality/ingestion/ValidationRunServiceTests.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -11,6 +12,9 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import io.github.nktogo.dataquality.operations.OperationsMetrics; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ValidationProcessingOutcome; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ValidationProcessingSample; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -31,14 +35,19 @@ class ValidationRunServiceTests { mock(ValidationRunRecoveryService.class); private final ValidationRunRepository validationRunRepository = mock(ValidationRunRepository.class); + private final OperationsMetrics operationsMetrics = mock(OperationsMetrics.class); + private final ValidationProcessingSample processingSample = + mock(ValidationProcessingSample.class); private final ValidationRunService service = - new ValidationRunService(lifecycleService, recoveryService, validationRunRepository); + new ValidationRunService( + lifecycleService, recoveryService, validationRunRepository, operationsMetrics); private final Logger serviceLogger = (Logger) LoggerFactory.getLogger(ValidationRunService.class); private final ListAppender logAppender = new ListAppender<>(); private boolean originalAdditive; @BeforeEach void setUp() { + when(operationsMetrics.startValidationProcessing()).thenReturn(processingSample); originalAdditive = serviceLogger.isAdditive(); serviceLogger.setAdditive(false); logAppender.start(); @@ -70,6 +79,9 @@ void createsPendingRunThenProcessesIt() { calls.verify(lifecycleService).createPending(fileId, profileId); calls.verify(lifecycleService).process(runId); verifyNoInteractions(recoveryService); + verify(operationsMetrics).incrementValidationRunsCreated(); + verify(operationsMetrics) + .recordValidationProcessing(processingSample, ValidationProcessingOutcome.COMPLETED); assertEvent("validation_run.created", Level.INFO, runId, null); assertEvent("validation_run.finished", Level.INFO, runId, null); } @@ -87,6 +99,8 @@ void recordsAHandledPersistedProcessingFailure() { service.create(fileId, new CreateValidationRunRequest(profileId)); assertThat(result).isSameAs(failed); + verify(operationsMetrics) + .recordValidationProcessing(processingSample, ValidationProcessingOutcome.FAILED); assertEvent("validation_run.processing_failed", Level.WARN, runId, null); } @@ -155,6 +169,8 @@ void recoversOnlyValidationProcessingFailuresAfterProcessingTransactionReturns() calls.verify(lifecycleService).createPending(fileId, profileId); calls.verify(lifecycleService).process(runId); calls.verify(recoveryService).recover(failure); + verify(operationsMetrics) + .recordValidationProcessing(processingSample, ValidationProcessingOutcome.FAILED); assertEvent("validation_run.execution_failed", Level.ERROR, runId, cause); assertEvent("validation_run.processing_failed", Level.WARN, runId, null); } @@ -172,6 +188,8 @@ void doesNotRecoverUnexpectedParserRuntimeFailure() { .isSameAs(failure); verifyNoInteractions(recoveryService); + verify(operationsMetrics) + .recordValidationProcessing(processingSample, ValidationProcessingOutcome.ERROR); assertEvent("validation_run.execution_failed", Level.ERROR, runId, failure); } @@ -191,6 +209,8 @@ void propagatesRecoveryFailure() { assertThatThrownBy(() -> service.create(fileId, new CreateValidationRunRequest(profileId))) .isSameAs(recoveryFailure); assertThat(recoveryFailure.getSuppressed()).containsExactly(processingFailure); + verify(operationsMetrics) + .recordValidationProcessing(processingSample, ValidationProcessingOutcome.ERROR); assertEvent( "validation_run.execution_failed", Level.ERROR, runId, processingFailure.getCause()); assertEvent("validation_run.recovery_failed", Level.ERROR, runId, recoveryFailure); diff --git a/backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsIntegrationTests.java b/backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsIntegrationTests.java new file mode 100644 index 0000000..f6f68c6 --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsIntegrationTests.java @@ -0,0 +1,121 @@ +package io.github.nktogo.dataquality.operations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportFormat; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportGenerationOutcome; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ValidationProcessingOutcome; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.core.env.Environment; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class OperationsIntegrationTests { + + @Container @ServiceConnection + private static final PostgreSQLContainer postgres = + new PostgreSQLContainer("postgres:18.4-alpine"); + + @Autowired private MockMvc mockMvc; + + @Autowired private OperationsMetrics operationsMetrics; + + @Autowired private Environment environment; + + @BeforeAll + void recordCustomMetrics() { + operationsMetrics.incrementValidationRunsCreated(); + + var validationSample = operationsMetrics.startValidationProcessing(); + operationsMetrics.recordValidationProcessing( + validationSample, ValidationProcessingOutcome.COMPLETED); + + operationsMetrics.incrementReportsGenerated(ReportFormat.JSON); + var reportSample = operationsMetrics.startReportGeneration(); + operationsMetrics.recordReportGeneration( + reportSample, ReportFormat.JSON, ReportGenerationOutcome.SUCCESS); + } + + @Test + void exposesHealthWithoutDetails() throws Exception { + mockMvc + .perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.components").doesNotExist()); + } + + @Test + void configuresLogstashStructuredConsoleOutput() { + assertThat(environment.getProperty("logging.structured.format.console")).isEqualTo("logstash"); + } + + @Test + void exposesRuntimeAndCustomMetricNames() throws Exception { + mockMvc + .perform(get("/actuator/metrics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.names", hasItem("jvm.memory.used"))) + .andExpect(jsonPath("$.names", hasItem("dataquality.validation.runs.created"))) + .andExpect(jsonPath("$.names", hasItem("dataquality.validation.processing.duration"))) + .andExpect(jsonPath("$.names", hasItem("dataquality.reports.generated"))) + .andExpect(jsonPath("$.names", hasItem("dataquality.report.generation.duration"))); + } + + @Test + void exposesJvmMetric() throws Exception { + mockMvc + .perform(get("/actuator/metrics/jvm.memory.used")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("jvm.memory.used")) + .andExpect(jsonPath("$.measurements.length()").value(greaterThanOrEqualTo(1))); + } + + @Test + void selectsOnlyLockedCustomMetricTagsAndIncludesApplicationTag() throws Exception { + mockMvc + .perform( + get("/actuator/metrics/dataquality.validation.processing.duration") + .queryParam("tag", "outcome:completed") + .queryParam("tag", "application:data-quality-backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("dataquality.validation.processing.duration")) + .andExpect(jsonPath("$.measurements[?(@.statistic == 'COUNT')].value", hasItem(1.0))); + + mockMvc + .perform( + get("/actuator/metrics/dataquality.report.generation.duration") + .queryParam("tag", "format:json") + .queryParam("tag", "outcome:success") + .queryParam("tag", "application:data-quality-backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("dataquality.report.generation.duration")) + .andExpect(jsonPath("$.measurements[?(@.statistic == 'COUNT')].value", hasItem(1.0))); + } + + @ParameterizedTest + @ValueSource( + strings = {"beans", "configprops", "env", "heapdump", "loggers", "mappings", "threaddump"}) + void doesNotExposeSensitiveActuatorEndpoints(String endpoint) throws Exception { + mockMvc.perform(get("/actuator/{endpoint}", endpoint)).andExpect(status().isNotFound()); + } +} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsMetricsTests.java b/backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsMetricsTests.java new file mode 100644 index 0000000..d3700ee --- /dev/null +++ b/backend/src/test/java/io/github/nktogo/dataquality/operations/OperationsMetricsTests.java @@ -0,0 +1,147 @@ +package io.github.nktogo.dataquality.operations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportFormat; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportGenerationOutcome; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ValidationProcessingOutcome; +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleConfig; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OperationsMetricsTests { + + private MockClock clock; + private SimpleMeterRegistry meterRegistry; + private OperationsMetrics operationsMetrics; + + @BeforeEach + void setUp() { + clock = new MockClock(); + meterRegistry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, clock); + operationsMetrics = new OperationsMetrics(meterRegistry); + } + + @Test + void incrementsValidationRunCreationCounterWithoutDomainTags() { + operationsMetrics.incrementValidationRunsCreated(); + operationsMetrics.incrementValidationRunsCreated(); + + var counter = meterRegistry.get("dataquality.validation.runs.created").counter(); + assertThat(counter.count()).isEqualTo(2.0); + assertThat(counter.getId().getTags()).isEmpty(); + } + + @Test + void recordsValidationProcessingDurationWithOnlyBoundedOutcomes() { + recordValidationDuration(ValidationProcessingOutcome.COMPLETED, Duration.ofMillis(125)); + recordValidationDuration(ValidationProcessingOutcome.FAILED, Duration.ofMillis(250)); + recordValidationDuration(ValidationProcessingOutcome.ERROR, Duration.ofMillis(500)); + + assertTimer("dataquality.validation.processing.duration", "completed", 125.0); + assertTimer("dataquality.validation.processing.duration", "failed", 250.0); + assertTimer("dataquality.validation.processing.duration", "error", 500.0); + assertThat(meterRegistry.find("dataquality.validation.processing.duration").timers()) + .hasSize(3); + } + + @Test + void incrementsReportCountersWithOnlyBoundedFormats() { + operationsMetrics.incrementReportsGenerated(ReportFormat.JSON); + operationsMetrics.incrementReportsGenerated(ReportFormat.CSV); + operationsMetrics.incrementReportsGenerated(ReportFormat.CSV); + + assertThat( + meterRegistry + .get("dataquality.reports.generated") + .tag("format", "json") + .counter() + .count()) + .isEqualTo(1.0); + assertThat( + meterRegistry + .get("dataquality.reports.generated") + .tag("format", "csv") + .counter() + .count()) + .isEqualTo(2.0); + assertThat(meterRegistry.find("dataquality.reports.generated").counters()).hasSize(2); + } + + @Test + void recordsReportGenerationDurationWithOnlyBoundedFormatsAndOutcomes() { + for (ReportFormat format : ReportFormat.values()) { + for (ReportGenerationOutcome outcome : ReportGenerationOutcome.values()) { + var sample = operationsMetrics.startReportGeneration(); + clock.add(Duration.ofMillis(100)); + operationsMetrics.recordReportGeneration(sample, format, outcome); + } + } + + assertReportTimer("json", "success"); + assertReportTimer("json", "error"); + assertReportTimer("csv", "success"); + assertReportTimer("csv", "error"); + assertThat(meterRegistry.find("dataquality.report.generation.duration").timers()).hasSize(4); + } + + @Test + void rejectsMissingSamplesAndDimensions() { + var validationSample = operationsMetrics.startValidationProcessing(); + var reportSample = operationsMetrics.startReportGeneration(); + + assertThatNullPointerException() + .isThrownBy( + () -> + operationsMetrics.recordValidationProcessing( + null, ValidationProcessingOutcome.COMPLETED)); + assertThatNullPointerException() + .isThrownBy(() -> operationsMetrics.recordValidationProcessing(validationSample, null)); + assertThatNullPointerException() + .isThrownBy(() -> operationsMetrics.incrementReportsGenerated(null)); + assertThatNullPointerException() + .isThrownBy( + () -> + operationsMetrics.recordReportGeneration( + null, ReportFormat.JSON, ReportGenerationOutcome.SUCCESS)); + assertThatNullPointerException() + .isThrownBy( + () -> + operationsMetrics.recordReportGeneration( + reportSample, null, ReportGenerationOutcome.SUCCESS)); + assertThatNullPointerException() + .isThrownBy( + () -> operationsMetrics.recordReportGeneration(reportSample, ReportFormat.JSON, null)); + } + + private void recordValidationDuration(ValidationProcessingOutcome outcome, Duration duration) { + var sample = operationsMetrics.startValidationProcessing(); + clock.add(duration); + operationsMetrics.recordValidationProcessing(sample, outcome); + } + + private void assertTimer(String meterName, String outcome, double expectedMilliseconds) { + Timer timer = meterRegistry.get(meterName).tag("outcome", outcome).timer(); + assertThat(timer.count()).isEqualTo(1L); + assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isEqualTo(expectedMilliseconds); + assertThat(timer.getId().getTags()).hasSize(1); + } + + private void assertReportTimer(String format, String outcome) { + Timer timer = + meterRegistry + .get("dataquality.report.generation.duration") + .tag("format", format) + .tag("outcome", outcome) + .timer(); + assertThat(timer.count()).isEqualTo(1L); + assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isEqualTo(100.0); + assertThat(timer.getId().getTags()).hasSize(2); + } +} diff --git a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java index 882b42a..769c7df 100644 --- a/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java +++ b/backend/src/test/java/io/github/nktogo/dataquality/reporting/ValidationReportControllerTests.java @@ -2,7 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -14,6 +17,9 @@ import io.github.nktogo.dataquality.dataset.ValidationRuleType; import io.github.nktogo.dataquality.ingestion.ValidationRunResponse; import io.github.nktogo.dataquality.ingestion.ValidationRunStatus; +import io.github.nktogo.dataquality.operations.OperationsMetrics; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportGenerationOutcome; +import io.github.nktogo.dataquality.operations.OperationsMetrics.ReportGenerationSample; import io.github.nktogo.dataquality.validation.ValidationIssueResponse; import jakarta.servlet.http.HttpServletRequest; import java.time.Instant; @@ -34,8 +40,11 @@ class ValidationReportControllerTests { mock(ValidationReportService.class); private final ValidationReportCsvWriter validationReportCsvWriter = mock(ValidationReportCsvWriter.class); + private final OperationsMetrics operationsMetrics = mock(OperationsMetrics.class); + private final ReportGenerationSample generationSample = mock(ReportGenerationSample.class); private final ValidationReportController controller = - new ValidationReportController(validationReportService, validationReportCsvWriter); + new ValidationReportController( + validationReportService, validationReportCsvWriter, operationsMetrics); private final Logger controllerLogger = (Logger) LoggerFactory.getLogger(ValidationReportController.class); private final ListAppender logAppender = new ListAppender<>(); @@ -43,6 +52,7 @@ class ValidationReportControllerTests { @BeforeEach void setUp() { + when(operationsMetrics.startReportGeneration()).thenReturn(generationSample); originalAdditive = controllerLogger.isAdditive(); controllerLogger.setAdditive(false); logAppender.start(); @@ -57,7 +67,7 @@ void tearDown() { } @Test - void generatesJsonWithADataSafeStructuredEvent() { + void generatesJsonWithSuccessMetricsAndADataSafeStructuredEvent() { ValidationReport report = report(); UUID runId = report.validationRun().id(); when(validationReportService.getReport(runId)).thenReturn(report); @@ -70,6 +80,10 @@ void generatesJsonWithADataSafeStructuredEvent() { assertThat(response.getHeaders().getContentDisposition().getFilename()) .isEqualTo("validation-run-" + runId + "-report.json"); verifyNoInteractions(validationReportCsvWriter); + verify(operationsMetrics).incrementReportsGenerated(OperationsMetrics.ReportFormat.JSON); + verify(operationsMetrics) + .recordReportGeneration( + generationSample, OperationsMetrics.ReportFormat.JSON, ReportGenerationOutcome.SUCCESS); ILoggingEvent event = generatedEvent(); assertThat(event.getLevel()).isEqualTo(Level.INFO); @@ -90,7 +104,7 @@ void generatesJsonWithADataSafeStructuredEvent() { } @Test - void generatesCsvWithADataSafeStructuredEvent() { + void generatesCsvBeforeRecordingSuccess() { ValidationReport report = report(); UUID runId = report.validationRun().id(); byte[] csv = "header\r\nvalue\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); @@ -102,6 +116,10 @@ void generatesCsvWithADataSafeStructuredEvent() { assertThat(response.getBody()).isSameAs(csv); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.parseMediaType("text/csv;charset=UTF-8")); + verify(operationsMetrics).incrementReportsGenerated(OperationsMetrics.ReportFormat.CSV); + verify(operationsMetrics) + .recordReportGeneration( + generationSample, OperationsMetrics.ReportFormat.CSV, ReportGenerationOutcome.SUCCESS); assertThat(generatedEvent().getKeyValuePairs()) .anySatisfy( pair -> { @@ -111,7 +129,7 @@ void generatesCsvWithADataSafeStructuredEvent() { } @Test - void doesNotClaimAReportWhenTheSnapshotCannotBeRead() { + void recordsAnErrorAndDoesNotClaimAReportWhenTheSnapshotCannotBeRead() { ValidationReport report = report(); UUID runId = report.validationRun().id(); IllegalStateException failure = new IllegalStateException("Database unavailable."); @@ -120,11 +138,15 @@ void doesNotClaimAReportWhenTheSnapshotCannotBeRead() { assertThatThrownBy(() -> controller.getReport(runId, requestWithFormat("json"))) .isSameAs(failure); + verify(operationsMetrics) + .recordReportGeneration( + generationSample, OperationsMetrics.ReportFormat.JSON, ReportGenerationOutcome.ERROR); + verify(operationsMetrics, never()).incrementReportsGenerated(any()); assertThat(logAppender.list).noneMatch(this::isGeneratedEvent); } @Test - void doesNotClaimAReportWhenCsvRenderingFails() { + void recordsAnErrorAndDoesNotClaimAReportWhenCsvRenderingFails() { ValidationReport report = report(); UUID runId = report.validationRun().id(); IllegalStateException failure = new IllegalStateException("CSV rendering failed."); @@ -134,6 +156,10 @@ void doesNotClaimAReportWhenCsvRenderingFails() { assertThatThrownBy(() -> controller.getReport(runId, requestWithFormat("csv"))) .isSameAs(failure); + verify(operationsMetrics) + .recordReportGeneration( + generationSample, OperationsMetrics.ReportFormat.CSV, ReportGenerationOutcome.ERROR); + verify(operationsMetrics, never()).incrementReportsGenerated(any()); assertThat(logAppender.list).noneMatch(this::isGeneratedEvent); }