diff --git a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobSnapshot.java b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobSnapshot.java index e8b20996..1ded14ca 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobSnapshot.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobSnapshot.java @@ -16,7 +16,8 @@ * @param jobRecordId opaque durable job identifier * @param jobStatus current stable lifecycle state * @param attemptCount number of worker claims recorded for this job - * @param failureCode stable terminal failure code, or {@code null} before failure + * @param failureCode non-blank stable terminal failure code when {@code jobStatus} is + * {@link EtlJobStatus#FAILED}; otherwise {@code null} * @param createdAt creation timestamp * @param updatedAt most recent state-change timestamp */ @@ -30,12 +31,12 @@ public record EtlJobSnapshot( ) { /** - * Validates the immutable status representation. + * Validates the immutable status representation and its lifecycle-dependent failure metadata. * * @param jobRecordId opaque durable job identifier * @param jobStatus current stable lifecycle state * @param attemptCount non-negative number of worker claims - * @param failureCode stable terminal failure code, or {@code null} + * @param failureCode non-blank stable failure code exactly when the job has failed * @param createdAt creation timestamp * @param updatedAt most recent state-change timestamp */ @@ -47,5 +48,16 @@ public record EtlJobSnapshot( if (attemptCount < 0) { throw new IllegalArgumentException("attemptCount must not be negative"); } + if (jobStatus == EtlJobStatus.FAILED) { + if (failureCode == null || failureCode.isBlank()) { + throw new IllegalArgumentException( + "failureCode must be non-blank when jobStatus is FAILED" + ); + } + } else if (failureCode != null) { + throw new IllegalArgumentException( + "failureCode must be null unless jobStatus is FAILED" + ); + } } } diff --git a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobStatusResponse.java b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobStatusResponse.java index abf23aa0..9975b49f 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobStatusResponse.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobStatusResponse.java @@ -18,7 +18,8 @@ * @param jobRecordId opaque durable job identifier * @param jobStatus current stable lifecycle state * @param attemptCount number of worker claims recorded for this job - * @param failureCode stable terminal failure code, omitted before failure + * @param failureCode non-blank stable terminal failure code when {@code jobStatus} is + * {@link EtlJobStatus#FAILED}; otherwise omitted as {@code null} * @param createdAt creation timestamp serialized as an ISO-8601 string * @param updatedAt most recent state-change timestamp serialized as an ISO-8601 string */ @@ -32,12 +33,12 @@ public record EtlJobStatusResponse( ) { /** - * Validates the immutable status response. + * Validates the immutable status response and its lifecycle-dependent failure metadata. * * @param jobRecordId opaque durable job identifier * @param jobStatus current stable lifecycle state * @param attemptCount non-negative worker claim count - * @param failureCode stable terminal failure code, or {@code null} + * @param failureCode non-blank stable failure code exactly when the job has failed * @param createdAt creation timestamp * @param updatedAt most recent state-change timestamp */ @@ -49,6 +50,17 @@ public record EtlJobStatusResponse( if (attemptCount < 0) { throw new IllegalArgumentException("attemptCount must not be negative"); } + if (jobStatus == EtlJobStatus.FAILED) { + if (failureCode == null || failureCode.isBlank()) { + throw new IllegalArgumentException( + "failureCode must be non-blank when jobStatus is FAILED" + ); + } + } else if (failureCode != null) { + throw new IllegalArgumentException( + "failureCode must be null unless jobStatus is FAILED" + ); + } } /** diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobModelTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobModelTest.java index cd7556ae..79a6b361 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobModelTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobModelTest.java @@ -62,6 +62,22 @@ void retainsValidSnapshotsAndMapsThemToStatusResponses() { assertEquals(UPDATED_AT, response.updatedAt()); } + @Test + void retainsValidFailedSnapshotsAndStatusResponses() { + EtlJobSnapshot snapshot = new EtlJobSnapshot( + JOB_RECORD_ID, + EtlJobStatus.FAILED, + 1, + "target_write_failed", + CREATED_AT, + UPDATED_AT + ); + EtlJobStatusResponse response = EtlJobStatusResponse.from(snapshot); + + assertEquals(EtlJobStatus.FAILED, response.jobStatus()); + assertEquals("target_write_failed", response.failureCode()); + } + @Test void rejectsNullRequiredModelValues() { assertThrows( @@ -144,4 +160,56 @@ void rejectsNegativeAttemptCounts() { ) ); } + + @Test + void rejectsFailureCodeOutsideFailedState() { + assertThrows( + IllegalArgumentException.class, + () -> new EtlJobSnapshot( + JOB_RECORD_ID, + EtlJobStatus.SUCCEEDED, + 1, + "target_write_failed", + CREATED_AT, + UPDATED_AT + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new EtlJobStatusResponse( + JOB_RECORD_ID, + EtlJobStatus.RUNNING, + 1, + "target_write_failed", + CREATED_AT, + UPDATED_AT + ) + ); + } + + @Test + void requiresNonBlankFailureCodeForFailedState() { + assertThrows( + IllegalArgumentException.class, + () -> new EtlJobSnapshot( + JOB_RECORD_ID, + EtlJobStatus.FAILED, + 1, + null, + CREATED_AT, + UPDATED_AT + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new EtlJobStatusResponse( + JOB_RECORD_ID, + EtlJobStatus.FAILED, + 1, + " ", + CREATED_AT, + UPDATED_AT + ) + ); + } }