diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 5d10c917..6027942a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -77,6 +77,8 @@ public enum ErrorCode { OCR_PROCESSING_FAILED, /** A training run is already in progress. 409 */ TRAINING_ALREADY_RUNNING, + /** Internal inconsistency: expected training run row was not found after creation. 500 */ + OCR_TRAINING_CONFLICT, // --- Tags --- /** A tag with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java index 8621a153..d02caad4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java @@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.OcrTrainingRun; import org.raddatz.familienarchiv.model.SenderModel; import org.raddatz.familienarchiv.model.TrainingStatus; @@ -64,8 +66,9 @@ public class SenderModelService { boolean runNow = runOrQueueSenderTraining(personId, (int) correctedLines); TrainingStatus targetStatus = runNow ? TrainingStatus.RUNNING : TrainingStatus.QUEUED; OcrTrainingRun run = trainingRunRepository.findFirstByPersonIdAndStatus(personId, targetStatus) - .orElseThrow(() -> new IllegalStateException( - "Expected " + targetStatus + " row for person " + personId)); + .orElseThrow(() -> DomainException.internal( + ErrorCode.OCR_TRAINING_CONFLICT, + "Expected " + targetStatus + " run for person " + personId)); if (runNow) { self.runSenderTraining(personId); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java index 3d999d5c..20086e10 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java @@ -64,6 +64,38 @@ class SenderModelServiceTest { ReflectionTestUtils.setField(service, "retrainDelta", 50); } + // ─── getAllSenderModels ─────────────────────────────────────────────────── + + @Test + void getAllSenderModels_returnsAllModelsFromRepository() { + SenderModel model = SenderModel.builder() + .id(UUID.randomUUID()).personId(personId).correctedLinesAtTraining(100).build(); + when(senderModelRepository.findAll()).thenReturn(java.util.List.of(model)); + + java.util.List result = service.getAllSenderModels(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getPersonId()).isEqualTo(personId); + } + + // ─── runSenderTraining ──────────────────────────────────────────────────── + + @Test + void runSenderTraining_queriesBlockCountForPerson() { + when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(42L); + // triggerSenderTraining needs a RUNNING row — return empty to abort early + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) + .thenReturn(Optional.empty()); + + try { + service.runSenderTraining(personId); + } catch (Exception ignored) { + // triggerSenderTraining will throw when no RUNNING row found + } + + verify(blockRepository).countManualKurrentBlocksByPerson(personId); + } + // ─── Activation threshold ───────────────────────────────────────────────── @Test @@ -318,6 +350,25 @@ class SenderModelServiceTest { .isInstanceOf(DomainException.class); } + @Test + void triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate() { + when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); + when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); + when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); + when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); + OcrTrainingRun runningRun = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .personId(personId).blockCount(0).documentCount(0).modelName("sender_" + personId).build(); + when(trainingRunRepository.save(any())).thenReturn(runningRun); + // Simulate the run row not being found after creation (defensive path) + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) + .thenReturn(Optional.empty()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> service.triggerManualSenderTraining(personId)) + .isInstanceOf(DomainException.class); + } + @Test void triggerSenderTraining_promotesNextQueued_afterCompletion() throws Exception { UUID nextPersonId = UUID.randomUUID(); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index f3979170..e9001fbb 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -27,6 +27,7 @@ export type ErrorCode = | 'OCR_DOCUMENT_NOT_UPLOADED' | 'OCR_PROCESSING_FAILED' | 'TRAINING_ALREADY_RUNNING' + | 'OCR_TRAINING_CONFLICT' | 'INVALID_TAG_COLOR' | 'TAG_CYCLE_DETECTED' | 'TAG_NOT_FOUND' @@ -105,6 +106,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_ocr_processing_failed(); case 'TRAINING_ALREADY_RUNNING': return m.error_training_already_running(); + case 'OCR_TRAINING_CONFLICT': + return m.error_internal_error(); case 'INVALID_TAG_COLOR': return m.error_invalid_tag_color(); case 'TAG_CYCLE_DETECTED':