1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 13:49:36 +01:00

feat(production): Vorwärts-Tracing für Rückruf-Szenario (US-P18)

BFS-Traversierung über Chargen-Genealogie (batch_consumptions.input_batch_id)
mit Cycle-Detection und Max-Depth-Guard. REST-Endpoint GET /{id}/trace-forward
liefert flache Liste mit Tiefenangabe für betroffene Endprodukt-Chargen.
This commit is contained in:
Sebastian Frick 2026-02-26 09:24:49 +01:00
parent 973c33d78f
commit ddb674d618
14 changed files with 822 additions and 1 deletions

View file

@ -0,0 +1,113 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.TraceBatchForwardCommand;
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("TraceBatchForward Use Case")
class TraceBatchForwardTest {
@Mock private BatchTraceabilityService traceabilityService;
@Mock private AuthorizationPort authPort;
private TraceBatchForward traceBatchForward;
private ActorId performedBy;
@BeforeEach
void setUp() {
traceBatchForward = new TraceBatchForward(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 = traceBatchForward.execute(new TraceBatchForwardCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(traceabilityService, never()).traceForward(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("child-1"), 1);
when(traceabilityService.traceForward(BatchId.of("batch-1")))
.thenReturn(Result.success(List.of(tracedBatch)));
var result = traceBatchForward.execute(new TraceBatchForwardCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
assertThat(result.unsafeGetValue().get(0).batch().id().value()).isEqualTo("child-1");
verify(traceabilityService).traceForward(BatchId.of("batch-1"));
}
@Test
@DisplayName("should return empty list when no downstream batches exist")
void should_ReturnEmptyList_When_NoDownstream() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(traceabilityService.traceForward(BatchId.of("batch-1")))
.thenReturn(Result.success(List.of()));
var result = traceBatchForward.execute(new TraceBatchForwardCommand("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.traceForward(BatchId.of("nonexistent")))
.thenReturn(Result.failure(new BatchError.BatchNotFound(BatchId.of("nonexistent"))));
var result = traceBatchForward.execute(new TraceBatchForwardCommand("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()
);
}
}

View file

@ -0,0 +1,138 @@
package de.effigenix.domain.production;
import com.code_intelligence.jazzer.api.FuzzedDataProvider;
import com.code_intelligence.jazzer.junit.FuzzTest;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
/**
* Fuzz test for BatchTraceabilityService.
*
* Generates random batch graphs (including cycles, diamonds, deep chains)
* and verifies that traceForward always terminates without exceptions.
* Uses an in-memory BatchRepository stub to build arbitrary graph topologies.
*
* Run: make fuzz | make fuzz/single TEST=BatchTraceabilityServiceFuzzTest
*/
class BatchTraceabilityServiceFuzzTest {
@FuzzTest(maxDuration = "5m")
void fuzzTraceForward(FuzzedDataProvider data) {
int nodeCount = data.consumeInt(1, 30);
List<String> nodeIds = new ArrayList<>();
for (int i = 0; i < nodeCount; i++) {
nodeIds.add("batch-" + i);
}
// Build random adjacency: for each node, pick random children
Map<String, List<String>> adjacency = new HashMap<>();
for (String nodeId : nodeIds) {
int childCount = data.consumeInt(0, Math.min(5, nodeCount));
List<String> 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);
// Pick random start node
String startId = nodeIds.get(data.consumeInt(0, nodeCount - 1));
int maxDepth = data.consumeInt(0, 15);
var result = service.traceForward(BatchId.of(startId), maxDepth);
// Must always terminate and return a valid Result never throw
switch (result) {
case Result.Success(var traced) -> {
// All depths must be within bounds
for (TracedBatch tb : traced) {
assert tb.depth() >= 1 && tb.depth() <= maxDepth;
}
// No duplicates
Set<String> seen = new HashSet<>();
for (TracedBatch tb : traced) {
assert seen.add(tb.batch().id().value()) : "Duplicate batch in result";
}
// Start batch must not be in result
for (TracedBatch tb : traced) {
assert !tb.batch().id().value().equals(startId) : "Start batch in result";
}
}
case Result.Failure(var err) -> {
// Failures are acceptable (e.g., RepositoryFailure), but must not be null
assert err != null;
}
}
}
private static Batch makeBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 1, 1), 1),
RecipeId.of("recipe-fuzz"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 1, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
}
/**
* In-memory BatchRepository that serves a pre-built graph.
* Only findById and findByInputBatchId are implemented (the rest throw).
*/
private static class InMemoryGraphRepository implements BatchRepository {
private final Set<String> nodeIds;
private final Map<String, List<String>> adjacency;
InMemoryGraphRepository(List<String> nodeIds, Map<String, List<String>> adjacency) {
this.nodeIds = new HashSet<>(nodeIds);
this.adjacency = adjacency;
}
@Override
public Result<RepositoryError, Optional<Batch>> findById(BatchId id) {
if (nodeIds.contains(id.value())) {
return Result.success(Optional.of(makeBatch(id.value())));
}
return Result.success(Optional.empty());
}
@Override
public Result<RepositoryError, List<Batch>> findByInputBatchId(BatchId inputBatchId) {
var children = adjacency.getOrDefault(inputBatchId.value(), List.of());
return Result.success(children.stream().map(BatchTraceabilityServiceFuzzTest::makeBatch).toList());
}
// --- Remaining methods are not used by BatchTraceabilityService ---
@Override public Result<RepositoryError, List<Batch>> findAll() { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, Optional<Batch>> findByBatchNumber(BatchNumber n) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findByStatus(BatchStatus s) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findByProductionDate(LocalDate d) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findByRecipeIds(List<RecipeId> ids) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findAllSummary() { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findByStatusSummary(BatchStatus s) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findByProductionDateSummary(LocalDate d) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, List<Batch>> findByRecipeIdsSummary(List<RecipeId> ids) { throw new UnsupportedOperationException(); }
@Override public Result<RepositoryError, Void> save(Batch batch) { throw new UnsupportedOperationException(); }
}
}

View file

@ -0,0 +1,255 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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 java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("BatchTraceabilityService")
class BatchTraceabilityServiceTest {
@Mock private BatchRepository batchRepository;
private BatchTraceabilityService service;
@BeforeEach
void setUp() {
service = new BatchTraceabilityService(batchRepository);
}
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()
);
}
@Nested
@DisplayName("traceForward")
class TraceForward {
@Test
@DisplayName("should return empty list when no downstream batches exist")
void should_ReturnEmptyList_When_NoDownstreamBatches() {
var startId = BatchId.of("start-batch");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should return direct downstream batches at depth 1")
void should_ReturnDirectDownstream_AtDepth1() {
var startId = BatchId.of("start-batch");
var child1 = sampleBatch("child-1");
var child2 = sampleBatch("child-2");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(child1, child2)));
when(batchRepository.findByInputBatchId(BatchId.of("child-1"))).thenReturn(Result.success(List.of()));
when(batchRepository.findByInputBatchId(BatchId.of("child-2"))).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(2);
assertThat(traced).allMatch(t -> t.depth() == 1);
assertThat(traced).extracting(t -> t.batch().id().value())
.containsExactlyInAnyOrder("child-1", "child-2");
}
@Test
@DisplayName("should traverse multi-level chain with correct depths")
void should_TraverseMultiLevel_WithCorrectDepths() {
var startId = BatchId.of("start");
var level1 = sampleBatch("level-1");
var level2 = sampleBatch("level-2");
var level3 = sampleBatch("level-3");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(level1)));
when(batchRepository.findByInputBatchId(BatchId.of("level-1"))).thenReturn(Result.success(List.of(level2)));
when(batchRepository.findByInputBatchId(BatchId.of("level-2"))).thenReturn(Result.success(List.of(level3)));
when(batchRepository.findByInputBatchId(BatchId.of("level-3"))).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(3);
assertThat(traced.get(0).batch().id().value()).isEqualTo("level-1");
assertThat(traced.get(0).depth()).isEqualTo(1);
assertThat(traced.get(1).batch().id().value()).isEqualTo("level-2");
assertThat(traced.get(1).depth()).isEqualTo(2);
assertThat(traced.get(2).batch().id().value()).isEqualTo("level-3");
assertThat(traced.get(2).depth()).isEqualTo(3);
}
@Test
@DisplayName("should detect cycles and terminate without endless loop")
void should_DetectCycles_AndTerminate() {
var startId = BatchId.of("start");
var child = sampleBatch("child");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(child)));
// child references back to start cycle
when(batchRepository.findByInputBatchId(BatchId.of("child")))
.thenReturn(Result.success(List.of(sampleBatch("start"))));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(1);
assertThat(traced.get(0).batch().id().value()).isEqualTo("child");
}
@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.traceForward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
}
@Test
@DisplayName("should stop at max depth limit")
void should_StopAtMaxDepth() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
var level1 = sampleBatch("level-1");
var level2 = sampleBatch("level-2");
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(level1)));
when(batchRepository.findByInputBatchId(BatchId.of("level-1"))).thenReturn(Result.success(List.of(level2)));
var result = service.traceForward(startId, 2);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).hasSize(2);
assertThat(traced).extracting(TracedBatch::depth).containsExactly(1, 2);
}
@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.traceForward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when findByInputBatchId returns error")
void should_FailWithRepositoryFailure_When_FindByInputBatchIdReturnsError() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
var result = service.traceForward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should deduplicate shared child in diamond graph")
void should_DeduplicateSharedChild_InDiamondGraph() {
// A B, A C, B D, C D D appears only once
var startId = BatchId.of("A");
var batchB = sampleBatch("B");
var batchC = sampleBatch("C");
var batchD = sampleBatch("D");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("A"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(batchB, batchC)));
when(batchRepository.findByInputBatchId(BatchId.of("B"))).thenReturn(Result.success(List.of(batchD)));
when(batchRepository.findByInputBatchId(BatchId.of("C"))).thenReturn(Result.success(List.of(sampleBatch("D"))));
when(batchRepository.findByInputBatchId(BatchId.of("D"))).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
var traced = result.unsafeGetValue();
assertThat(traced).extracting(t -> t.batch().id().value())
.containsExactlyInAnyOrder("B", "C", "D");
// D must appear only once despite being reachable from B and C
assertThat(traced.stream().filter(t -> t.batch().id().value().equals("D")).count())
.isEqualTo(1);
}
@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.traceForward(startId, 0);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should not include start batch in result")
void should_NotIncludeStartBatch_InResult() {
var startId = BatchId.of("start");
var child = sampleBatch("child");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.findByInputBatchId(startId)).thenReturn(Result.success(List.of(child)));
when(batchRepository.findByInputBatchId(BatchId.of("child"))).thenReturn(Result.success(List.of()));
var result = service.traceForward(startId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).extracting(t -> t.batch().id().value())
.doesNotContain("start");
}
}
}

View file

@ -13,6 +13,7 @@ import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
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.*;
@ -666,6 +667,120 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
}
}
@Nested
@DisplayName("GET /api/production/batches/{id}/trace-forward Vorwärts-Tracing")
class TraceForwardEndpoint {
private String traceToken;
@BeforeEach
void setUpTraceToken() {
traceToken = generateToken(UUID.randomUUID().toString(), "trace.admin",
"BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
}
@Test
@DisplayName("Kein Downstream → 200, leere Liste")
void traceForward_noDownstream_returnsEmptyList() throws Exception {
String batchId = createPlannedBatchWith(traceToken);
mockMvc.perform(get("/api/production/batches/{id}/trace-forward", 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")
void traceForward_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-forward", inputBatchId)
.header("Authorization", "Bearer " + traceToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.originBatchId").value(inputBatchId))
.andExpect(jsonPath("$.totalCount").value(1))
.andExpect(jsonPath("$.tracedBatches[0].id").value(outputBatchId))
.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 traceForward_notFound_returns404() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}/trace-forward", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + traceToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Ohne Token → 401")
void traceForward_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}/trace-forward", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne BATCH_READ → 403")
void traceForward_withoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}/trace-forward", 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