diff --git a/backend/src/main/java/de/effigenix/application/production/TraceBatchBackward.java b/backend/src/main/java/de/effigenix/application/production/TraceBatchBackward.java new file mode 100644 index 0000000..51d1a77 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/TraceBatchBackward.java @@ -0,0 +1,28 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.TraceBatchBackwardCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; + +import java.util.List; + +public class TraceBatchBackward { + + private final BatchTraceabilityService traceabilityService; + private final AuthorizationPort authorizationPort; + + public TraceBatchBackward(BatchTraceabilityService traceabilityService, AuthorizationPort authorizationPort) { + this.traceabilityService = traceabilityService; + this.authorizationPort = authorizationPort; + } + + public Result> execute(TraceBatchBackwardCommand command, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + return traceabilityService.traceBackward(BatchId.of(command.batchId())); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/command/TraceBatchBackwardCommand.java b/backend/src/main/java/de/effigenix/application/production/command/TraceBatchBackwardCommand.java new file mode 100644 index 0000000..14ae17d --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/TraceBatchBackwardCommand.java @@ -0,0 +1,4 @@ +package de.effigenix.application.production.command; + +public record TraceBatchBackwardCommand(String batchId) { +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java index a22842d..5f4aef0 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java @@ -33,5 +33,7 @@ public interface BatchRepository { Result> traceForward(BatchId startBatchId, int maxDepth); + Result> traceBackward(BatchId startBatchId, int maxDepth); + Result save(Batch batch); } diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchTraceabilityService.java b/backend/src/main/java/de/effigenix/domain/production/BatchTraceabilityService.java index 94b3c36..621202a 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchTraceabilityService.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchTraceabilityService.java @@ -40,4 +40,31 @@ public class BatchTraceabilityService { Result.success(traced); }; } + + public Result> traceBackward(BatchId startBatchId) { + return traceBackward(startBatchId, DEFAULT_MAX_DEPTH); + } + + public Result> traceBackward(BatchId startBatchId, int maxDepth) { + switch (batchRepository.findById(startBatchId)) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new BatchError.BatchNotFound(startBatchId)); + } + } + } + + if (maxDepth <= 0) { + return Result.success(List.of()); + } + + return switch (batchRepository.traceBackward(startBatchId, maxDepth)) { + case Result.Failure(var err) -> + Result.failure(new BatchError.RepositoryFailure(err.message())); + case Result.Success(var traced) -> + Result.success(traced); + }; + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index 3040e69..1f88d58 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -15,6 +15,7 @@ import de.effigenix.application.production.StartProductionOrder; import de.effigenix.application.production.CancelBatch; import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.TraceBatchBackward; import de.effigenix.application.production.TraceBatchForward; import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.GetBatch; @@ -155,6 +156,12 @@ public class ProductionUseCaseConfiguration { return new TraceBatchForward(batchTraceabilityService, authorizationPort); } + @Bean + public TraceBatchBackward traceBatchBackward(BatchTraceabilityService batchTraceabilityService, + AuthorizationPort authorizationPort) { + return new TraceBatchBackward(batchTraceabilityService, authorizationPort); + } + @Bean public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository, RecipeRepository recipeRepository, diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcBatchRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcBatchRepository.java index 1351327..fa60b2f 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcBatchRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcBatchRepository.java @@ -250,6 +250,44 @@ public class JdbcBatchRepository implements BatchRepository { } } + @Override + public Result> traceBackward(BatchId startBatchId, int maxDepth) { + try { + String startId = startBatchId.value(); + var results = new ArrayList(); + var visited = new HashSet(); + visited.add(startId); + + var currentLevel = List.of(startId); + for (int depth = 1; depth <= maxDepth && !currentLevel.isEmpty(); depth++) { + int currentDepth = depth; + var parents = jdbc.sql(""" + SELECT DISTINCT b.* + FROM batches b + JOIN batch_consumptions bc ON b.id = bc.input_batch_id + WHERE bc.batch_id IN (:childIds) + """) + .param("childIds", currentLevel) + .query(this::mapBatchRow) + .list(); + + var nextLevel = new ArrayList(); + for (Batch parent : parents) { + if (visited.add(parent.id().value())) { + results.add(new TracedBatch(parent, currentDepth)); + nextLevel.add(parent.id().value()); + } + } + currentLevel = nextLevel; + } + + return Result.success(results); + } catch (Exception e) { + logger.warn("Database error in traceBackward", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override public Result save(Batch batch) { try { diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java index 47bbd5e..ea7c8c9 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java @@ -8,12 +8,14 @@ import de.effigenix.application.production.ListBatches; import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.RecordConsumption; import de.effigenix.application.production.StartBatch; +import de.effigenix.application.production.TraceBatchBackward; import de.effigenix.application.production.TraceBatchForward; import de.effigenix.application.production.command.CancelBatchCommand; import de.effigenix.application.production.command.CompleteBatchCommand; import de.effigenix.application.production.command.PlanBatchCommand; import de.effigenix.application.production.command.RecordConsumptionCommand; import de.effigenix.application.production.command.StartBatchCommand; +import de.effigenix.application.production.command.TraceBatchBackwardCommand; import de.effigenix.application.production.command.TraceBatchForwardCommand; import de.effigenix.domain.production.BatchError; import de.effigenix.domain.production.BatchId; @@ -22,6 +24,7 @@ import de.effigenix.domain.production.BatchStatus; import de.effigenix.infrastructure.production.web.dto.BatchResponse; import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse; import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse; +import de.effigenix.infrastructure.production.web.dto.TraceBatchBackwardResponse; import de.effigenix.infrastructure.production.web.dto.TraceBatchForwardResponse; import de.effigenix.infrastructure.production.web.dto.CancelBatchRequest; import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest; @@ -60,11 +63,13 @@ public class BatchController { private final CompleteBatch completeBatch; private final CancelBatch cancelBatch; private final TraceBatchForward traceBatchForward; + private final TraceBatchBackward traceBatchBackward; public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, FindBatchByNumber findBatchByNumber, StartBatch startBatch, RecordConsumption recordConsumption, CompleteBatch completeBatch, - CancelBatch cancelBatch, TraceBatchForward traceBatchForward) { + CancelBatch cancelBatch, TraceBatchForward traceBatchForward, + TraceBatchBackward traceBatchBackward) { this.planBatch = planBatch; this.getBatch = getBatch; this.listBatches = listBatches; @@ -74,6 +79,7 @@ public class BatchController { this.completeBatch = completeBatch; this.cancelBatch = cancelBatch; this.traceBatchForward = traceBatchForward; + this.traceBatchBackward = traceBatchBackward; } @GetMapping("/{id}") @@ -266,6 +272,23 @@ public class BatchController { return ResponseEntity.ok(TraceBatchForwardResponse.from(id, result.unsafeGetValue())); } + @GetMapping("/{id}/trace-backward") + @PreAuthorize("hasAuthority('BATCH_READ')") + public ResponseEntity traceBackward( + @PathVariable("id") String id, + Authentication authentication + ) { + var actorId = ActorId.of(authentication.getName()); + var cmd = new TraceBatchBackwardCommand(id); + var result = traceBatchBackward.execute(cmd, actorId); + + if (result.isFailure()) { + throw new BatchDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(TraceBatchBackwardResponse.from(id, result.unsafeGetValue())); + } + @PostMapping("/{id}/cancel") @PreAuthorize("hasAuthority('BATCH_CANCEL')") public ResponseEntity cancelBatch( diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/TraceBatchBackwardResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/TraceBatchBackwardResponse.java new file mode 100644 index 0000000..33e7eaa --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/TraceBatchBackwardResponse.java @@ -0,0 +1,37 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.TracedBatch; + +import java.util.List; + +public record TraceBatchBackwardResponse( + String originBatchId, + List tracedBatches, + int totalCount +) { + public record TracedBatchResponse( + String id, + String batchNumber, + String recipeId, + String status, + int depth + ) { + public static TracedBatchResponse from(TracedBatch traced) { + var batch = traced.batch(); + return new TracedBatchResponse( + batch.id().value(), + batch.batchNumber().value(), + batch.recipeId().value(), + batch.status().name(), + traced.depth() + ); + } + } + + public static TraceBatchBackwardResponse from(String originBatchId, List tracedBatches) { + var responses = tracedBatches.stream() + .map(TracedBatchResponse::from) + .toList(); + return new TraceBatchBackwardResponse(originBatchId, responses, responses.size()); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubBatchRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubBatchRepository.java index 961e71d..2636408 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubBatchRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubBatchRepository.java @@ -83,6 +83,11 @@ public class StubBatchRepository implements BatchRepository { return Result.failure(STUB_ERROR); } + @Override + public Result> traceBackward(BatchId startBatchId, int maxDepth) { + return Result.failure(STUB_ERROR); + } + @Override public Result save(Batch batch) { return Result.failure(STUB_ERROR); diff --git a/backend/src/test/java/de/effigenix/application/production/TraceBatchBackwardTest.java b/backend/src/test/java/de/effigenix/application/production/TraceBatchBackwardTest.java new file mode 100644 index 0000000..e18a8ac --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/TraceBatchBackwardTest.java @@ -0,0 +1,113 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.TraceBatchBackwardCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TraceBatchBackward Use Case") +class TraceBatchBackwardTest { + + @Mock private BatchTraceabilityService traceabilityService; + @Mock private AuthorizationPort authPort; + + private TraceBatchBackward traceBatchBackward; + private ActorId performedBy; + + @BeforeEach + void setUp() { + traceBatchBackward = new TraceBatchBackward(traceabilityService, authPort); + performedBy = ActorId.of("admin-user"); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized_When_ActorLacksPermission() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("batch-1"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(traceabilityService, never()).traceBackward(any()); + } + + @Test + @DisplayName("should delegate to BatchTraceabilityService when authorized") + void should_DelegateToService_When_Authorized() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + + var tracedBatch = new TracedBatch(sampleBatch("parent-1"), 1); + when(traceabilityService.traceBackward(BatchId.of("batch-1"))) + .thenReturn(Result.success(List.of(tracedBatch))); + + var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("batch-1"), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(result.unsafeGetValue().get(0).batch().id().value()).isEqualTo("parent-1"); + verify(traceabilityService).traceBackward(BatchId.of("batch-1")); + } + + @Test + @DisplayName("should return empty list when no upstream batches exist") + void should_ReturnEmptyList_When_NoUpstream() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(traceabilityService.traceBackward(BatchId.of("batch-1"))) + .thenReturn(Result.success(List.of())); + + var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("batch-1"), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should propagate domain error from service") + void should_PropagateDomainError_FromService() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(traceabilityService.traceBackward(BatchId.of("nonexistent"))) + .thenReturn(Result.failure(new BatchError.BatchNotFound(BatchId.of("nonexistent")))); + + var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("nonexistent"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class); + } + + private Batch sampleBatch(String id) { + return Batch.reconstitute( + BatchId.of(id), + BatchNumber.generate(LocalDate.of(2026, 3, 1), 1), + RecipeId.of("recipe-1"), + BatchStatus.COMPLETED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + null, null, null, + LocalDate.of(2026, 3, 1), + LocalDate.of(2026, 6, 1), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + null, null, null, + 0L, List.of() + ); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceFuzzTest.java b/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceFuzzTest.java index be886a0..b58a14e 100644 --- a/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceFuzzTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceFuzzTest.java @@ -24,6 +24,52 @@ import java.util.*; */ class BatchTraceabilityServiceFuzzTest { + @FuzzTest(maxDuration = "5m") + void fuzzTraceBackward(FuzzedDataProvider data) { + int nodeCount = data.consumeInt(1, 30); + List nodeIds = new ArrayList<>(); + for (int i = 0; i < nodeCount; i++) { + nodeIds.add("batch-" + i); + } + + Map> adjacency = new HashMap<>(); + for (String nodeId : nodeIds) { + int childCount = data.consumeInt(0, Math.min(5, nodeCount)); + List children = new ArrayList<>(); + for (int c = 0; c < childCount; c++) { + int childIdx = data.consumeInt(0, nodeCount - 1); + children.add(nodeIds.get(childIdx)); + } + adjacency.put(nodeId, children); + } + + var repo = new InMemoryGraphRepository(nodeIds, adjacency); + var service = new BatchTraceabilityService(repo); + + String startId = nodeIds.get(data.consumeInt(0, nodeCount - 1)); + int maxDepth = data.consumeInt(0, 15); + + var result = service.traceBackward(BatchId.of(startId), maxDepth); + + switch (result) { + case Result.Success(var traced) -> { + for (TracedBatch tb : traced) { + assert tb.depth() >= 1 && tb.depth() <= maxDepth; + } + Set seen = new HashSet<>(); + for (TracedBatch tb : traced) { + assert seen.add(tb.batch().id().value()) : "Duplicate batch in result"; + } + for (TracedBatch tb : traced) { + assert !tb.batch().id().value().equals(startId) : "Start batch in result"; + } + } + case Result.Failure(var err) -> { + assert err != null; + } + } + } + @FuzzTest(maxDuration = "5m") void fuzzTraceForward(FuzzedDataProvider data) { int nodeCount = data.consumeInt(1, 30); @@ -149,6 +195,41 @@ class BatchTraceabilityServiceFuzzTest { return Result.success(result); } + @Override + public Result> traceBackward(BatchId startBatchId, int maxDepth) { + // Invert adjacency: for traceBackward, find parents (nodes whose children include startBatchId) + Map> reverseAdj = new HashMap<>(); + for (var entry : adjacency.entrySet()) { + for (String child : entry.getValue()) { + reverseAdj.computeIfAbsent(child, k -> new ArrayList<>()).add(entry.getKey()); + } + } + + List result = new ArrayList<>(); + Set visited = new HashSet<>(); + visited.add(startBatchId.value()); + + record BfsEntry(String batchId, int depth) {} + var queue = new ArrayDeque(); + queue.add(new BfsEntry(startBatchId.value(), 0)); + + while (!queue.isEmpty()) { + var entry = queue.poll(); + if (entry.depth() >= maxDepth) continue; + + var parents = reverseAdj.getOrDefault(entry.batchId(), List.of()); + int parentDepth = entry.depth() + 1; + for (String parentId : parents) { + if (visited.add(parentId)) { + result.add(new TracedBatch(makeBatch(parentId), parentDepth)); + queue.add(new BfsEntry(parentId, parentDepth)); + } + } + } + + return Result.success(result); + } + // --- Remaining methods are not used by BatchTraceabilityService --- @Override public Result> findAll() { throw new UnsupportedOperationException(); } diff --git a/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceTest.java b/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceTest.java index de6ef1e..59d0dc6 100644 --- a/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/BatchTraceabilityServiceTest.java @@ -157,4 +157,110 @@ class BatchTraceabilityServiceTest { assertThat(result.unsafeGetValue()).isEmpty(); } } + + @Nested + @DisplayName("traceBackward") + class TraceBackward { + + @Test + @DisplayName("should fail with BatchNotFound when start batch does not exist") + void should_FailWithBatchNotFound_When_StartBatchDoesNotExist() { + var startId = BatchId.of("nonexistent"); + when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.empty())); + + var result = service.traceBackward(startId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class); + verify(batchRepository, never()).traceBackward(any(), anyInt()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findById returns error") + void should_FailWithRepositoryFailure_When_FindByIdReturnsError() { + var startId = BatchId.of("start"); + when(batchRepository.findById(startId)) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = service.traceBackward(startId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + verify(batchRepository, never()).traceBackward(any(), anyInt()); + } + + @Test + @DisplayName("should delegate to repository traceBackward when start batch exists") + void should_DelegateToRepository_When_StartBatchExists() { + var startId = BatchId.of("start"); + when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start")))); + + var traced = List.of( + new TracedBatch(sampleBatch("parent-1"), 1), + new TracedBatch(sampleBatch("parent-2"), 1) + ); + when(batchRepository.traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH)) + .thenReturn(Result.success(traced)); + + var result = service.traceBackward(startId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(2); + verify(batchRepository).traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH); + } + + @Test + @DisplayName("should pass custom maxDepth to repository") + void should_PassCustomMaxDepth_ToRepository() { + var startId = BatchId.of("start"); + when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start")))); + when(batchRepository.traceBackward(startId, 5)).thenReturn(Result.success(List.of())); + + var result = service.traceBackward(startId, 5); + + assertThat(result.isSuccess()).isTrue(); + verify(batchRepository).traceBackward(startId, 5); + } + + @Test + @DisplayName("should return empty list when maxDepth is 0") + void should_ReturnEmptyList_When_MaxDepthIsZero() { + var startId = BatchId.of("start"); + when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start")))); + + var result = service.traceBackward(startId, 0); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + verify(batchRepository, never()).traceBackward(any(), anyInt()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when traceBackward returns error") + void should_FailWithRepositoryFailure_When_TraceBackwardReturnsError() { + var startId = BatchId.of("start"); + when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start")))); + when(batchRepository.traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH)) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout"))); + + var result = service.traceBackward(startId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should return empty list when no upstream batches exist") + void should_ReturnEmptyList_When_NoUpstreamBatches() { + var startId = BatchId.of("start-batch"); + when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch")))); + when(batchRepository.traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH)) + .thenReturn(Result.success(List.of())); + + var result = service.traceBackward(startId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + } } diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java index 401ec3b..34d7145 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java @@ -781,6 +781,122 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== GET /api/production/batches/{id}/trace-backward – Rückwärts-Tracing ==================== + + @Nested + @DisplayName("GET /api/production/batches/{id}/trace-backward – Rückwärts-Tracing") + class TraceBackwardEndpoint { + + private String traceToken; + + @BeforeEach + void setUpTraceToken() { + traceToken = generateToken(UUID.randomUUID().toString(), "trace.admin", + "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ"); + } + + @Test + @DisplayName("Kein Upstream → 200, leere Liste") + void traceBackward_noUpstream_returnsEmptyList() throws Exception { + String batchId = createPlannedBatchWith(traceToken); + + mockMvc.perform(get("/api/production/batches/{id}/trace-backward", batchId) + .header("Authorization", "Bearer " + traceToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.originBatchId").value(batchId)) + .andExpect(jsonPath("$.tracedBatches").isArray()) + .andExpect(jsonPath("$.tracedBatches").isEmpty()) + .andExpect(jsonPath("$.totalCount").value(0)); + } + + @Test + @DisplayName("Single-Level Kette → 200, depth=1, upstream Chargen") + void traceBackward_singleLevel_returnsDepth1() throws Exception { + // Rohstoff-Charge (Input) + String inputBatchId = createPlannedBatchWith(traceToken); + + // Endprodukt-Charge verbraucht die Input-Charge + String outputBatchId = createBatchWithConsumption(traceToken, inputBatchId); + + mockMvc.perform(get("/api/production/batches/{id}/trace-backward", outputBatchId) + .header("Authorization", "Bearer " + traceToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.originBatchId").value(outputBatchId)) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.tracedBatches[0].id").value(inputBatchId)) + .andExpect(jsonPath("$.tracedBatches[0].depth").value(1)) + .andExpect(jsonPath("$.tracedBatches[0].batchNumber").isNotEmpty()) + .andExpect(jsonPath("$.tracedBatches[0].status").isNotEmpty()); + } + + @Test + @DisplayName("Batch nicht gefunden → 404") + void traceBackward_notFound_returns404() throws Exception { + mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + traceToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND")); + } + + @Test + @DisplayName("Ohne Token → 401") + void traceBackward_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Ohne BATCH_READ → 403") + void traceBackward_withoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + private String createPlannedBatchWith(String token) throws Exception { + String recipeId = createActiveRecipeWith(token); + var planRequest = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + var planResult = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(planRequest))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText(); + } + + private String createBatchWithConsumption(String token, String inputBatchId) throws Exception { + String recipeId = createActiveRecipeWith(token); + var planRequest = new PlanBatchRequest( + recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + var planResult = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(planRequest))) + .andExpect(status().isCreated()) + .andReturn(); + String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText(); + + // Start production + mockMvc.perform(post("/api/production/batches/{id}/start", batchId) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + + // Record consumption with inputBatchId + String consumptionJson = """ + {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "10.0", "quantityUnit": "KILOGRAM"} + """.formatted(inputBatchId, UUID.randomUUID().toString()); + mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(consumptionJson)) + .andExpect(status().isCreated()); + + return batchId; + } + } + // ==================== POST /api/production/batches/{id}/start – ungültige Übergänge ==================== @Nested diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java index 85ce7a4..3a65ba4 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java @@ -89,6 +89,34 @@ public final class ProductionScenario { ); } + // ---- Tracing ---- + + public static ChainBuilder traceForward() { + return exec(session -> { + var ids = LoadTestDataSeeder.batchIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("traceBatchId", id); + }).exec( + http("Charge vorwärts tracen") + .get("/api/production/batches/#{traceBatchId}/trace-forward") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 404)) + ); + } + + public static ChainBuilder traceBackward() { + return exec(session -> { + var ids = LoadTestDataSeeder.batchIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("traceBatchId", id); + }).exec( + http("Charge rückwärts tracen") + .get("/api/production/batches/#{traceBatchId}/trace-backward") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 404)) + ); + } + // ---- Produktionsaufträge ---- public static ChainBuilder createProductionOrder() { @@ -259,6 +287,11 @@ public final class ProductionScenario { .pause(1, 2) .exec(listProductionOrdersByStatus()) .pause(1, 2) + // Tracing-Szenarien + .exec(traceForward()) + .pause(1, 2) + .exec(traceBackward()) + .pause(1, 2) // Nochmal Chargen-Liste prüfen .exec(listBatches()); } @@ -271,11 +304,13 @@ public final class ProductionScenario { .exec(AuthenticationScenario.login("admin", "admin123")) .repeat(15).on( randomSwitch().on( - percent(30.0).then(listRecipes()), - percent(20.0).then(getRandomRecipe()), - percent(20.0).then(listBatches()), - percent(15.0).then(listProductionOrders()), - percent(15.0).then(listProductionOrdersByStatus()) + percent(25.0).then(listRecipes()), + percent(15.0).then(getRandomRecipe()), + percent(15.0).then(listBatches()), + percent(10.0).then(listProductionOrders()), + percent(10.0).then(listProductionOrdersByStatus()), + percent(12.5).then(traceForward()), + percent(12.5).then(traceBackward()) ).pause(1, 3) ); } diff --git a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java index 960657f..6cc9c64 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java @@ -127,6 +127,10 @@ public class FullWorkloadSimulation extends Simulation { details("Inventur starten").responseTime().mean().lt(50), details("Ist-Menge erfassen").responseTime().mean().lt(50), + // Tracing: BFS-Traversierung, mean < 50ms + details("Charge vorwärts tracen").responseTime().mean().lt(50), + details("Charge rückwärts tracen").responseTime().mean().lt(50), + // Produktionsaufträge-Listen: mean < 35ms details("Produktionsaufträge auflisten").responseTime().mean().lt(35), details("Produktionsaufträge nach Status").responseTime().mean().lt(35)