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

feat(production): Charge planen (PlanBatch) (#33)

Batch-Aggregat mit plan()-Factory, automatischer BatchNumber-Generierung
(P-YYYY-MM-DD-XXX) und Validierung (Quantity > 0, bestBeforeDate > productionDate).
Full Vertical Slice: Domain, Application, Infrastructure (JPA, REST, Liquibase).
This commit is contained in:
Sebastian Frick 2026-02-19 23:51:36 +01:00
parent d963d7fccc
commit b06157b92c
25 changed files with 1541 additions and 0 deletions

View file

@ -0,0 +1,216 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.PlanBatchCommand;
import 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 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.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("PlanBatch Use Case")
class PlanBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private RecipeRepository recipeRepository;
@Mock private BatchNumberGenerator batchNumberGenerator;
@Mock private AuthorizationPort authPort;
private PlanBatch planBatch;
private ActorId performedBy;
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(PRODUCTION_DATE, 1);
@BeforeEach
void setUp() {
planBatch = new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authPort);
performedBy = ActorId.of("admin-user");
}
private PlanBatchCommand validCommand() {
return new PlanBatchCommand("recipe-1", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
}
private Recipe activeRecipe() {
return Recipe.reconstitute(
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(),
LocalDateTime.now(), LocalDateTime.now()
);
}
private Recipe draftRecipe() {
return Recipe.reconstitute(
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.DRAFT, List.of(), List.of(),
LocalDateTime.now(), LocalDateTime.now()
);
}
@Test
@DisplayName("should plan batch when recipe is ACTIVE and all data valid")
void should_PlanBatch_When_ValidCommand() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.of(activeRecipe())));
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
.thenReturn(Result.success(BATCH_NUMBER));
when(batchRepository.save(any())).thenReturn(Result.success(null));
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isSuccess()).isTrue();
var batch = result.unsafeGetValue();
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER);
assertThat(batch.recipeId().value()).isEqualTo("recipe-1");
verify(batchRepository).save(any(Batch.class));
}
@Test
@DisplayName("should fail when actor lacks BATCH_WRITE permission")
void should_Fail_When_Unauthorized() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false);
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when recipe not found")
void should_Fail_When_RecipeNotFound() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.empty()));
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
assertThat(result.unsafeGetError().message()).contains("recipe-1");
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when recipe is not ACTIVE")
void should_Fail_When_RecipeNotActive() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.of(draftRecipe())));
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RecipeNotActive.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when recipe repository returns error")
void should_Fail_When_RecipeRepositoryError() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when batch number generation fails")
void should_Fail_When_BatchNumberGenerationFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.of(activeRecipe())));
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
.thenReturn(Result.failure(new BatchError.ValidationFailure("Sequence exhausted")));
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when batch repository save fails")
void should_Fail_When_BatchSaveFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.of(activeRecipe())));
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
.thenReturn(Result.success(BATCH_NUMBER));
when(batchRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
var result = planBatch.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when planned quantity is invalid")
void should_Fail_When_InvalidQuantity() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.of(activeRecipe())));
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
.thenReturn(Result.success(BATCH_NUMBER));
var cmd = new PlanBatchCommand("recipe-1", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = planBatch.execute(cmd, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when dates are invalid")
void should_Fail_When_InvalidDates() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(recipeRepository.findById(RecipeId.of("recipe-1")))
.thenReturn(Result.success(Optional.of(activeRecipe())));
when(batchNumberGenerator.generateNext(LocalDate.of(2026, 6, 1)))
.thenReturn(Result.success(BatchNumber.generate(LocalDate.of(2026, 6, 1), 1)));
var cmd = new PlanBatchCommand("recipe-1", "100", "KILOGRAM",
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));
var result = planBatch.execute(cmd, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
verify(batchRepository, never()).save(any());
}
}

View file

@ -0,0 +1,198 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Batch Aggregate")
class BatchTest {
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(PRODUCTION_DATE, 1);
private BatchDraft validDraft() {
return new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
}
@Nested
@DisplayName("plan()")
class Plan {
@Test
@DisplayName("should create batch with valid draft")
void should_CreateBatch_When_ValidDraft() {
var result = Batch.plan(validDraft(), BATCH_NUMBER);
assertThat(result.isSuccess()).isTrue();
var batch = result.unsafeGetValue();
assertThat(batch.id()).isNotNull();
assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER);
assertThat(batch.recipeId().value()).isEqualTo("recipe-123");
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
assertThat(batch.plannedQuantity().amount()).isEqualByComparingTo(new BigDecimal("100"));
assertThat(batch.plannedQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(batch.productionDate()).isEqualTo(PRODUCTION_DATE);
assertThat(batch.bestBeforeDate()).isEqualTo(BEST_BEFORE_DATE);
assertThat(batch.createdAt()).isNotNull();
assertThat(batch.updatedAt()).isNotNull();
}
@Test
@DisplayName("should fail when recipeId is blank")
void should_Fail_When_RecipeIdBlank() {
var draft = new BatchDraft("", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when recipeId is null")
void should_Fail_When_RecipeIdNull() {
var draft = new BatchDraft(null, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when plannedQuantity is zero")
void should_Fail_When_QuantityZero() {
var draft = new BatchDraft("recipe-123", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
}
@Test
@DisplayName("should fail when plannedQuantity is negative")
void should_Fail_When_QuantityNegative() {
var draft = new BatchDraft("recipe-123", "-5", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
}
@Test
@DisplayName("should fail when plannedQuantity is not a number")
void should_Fail_When_QuantityNotANumber() {
var draft = new BatchDraft("recipe-123", "abc", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
}
@Test
@DisplayName("should fail when unit is invalid")
void should_Fail_When_UnitInvalid() {
var draft = new BatchDraft("recipe-123", "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
}
@Test
@DisplayName("should fail when bestBeforeDate is before productionDate")
void should_Fail_When_BestBeforeDateBeforeProductionDate() {
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM",
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
}
@Test
@DisplayName("should fail when bestBeforeDate equals productionDate")
void should_Fail_When_BestBeforeDateEqualsProductionDate() {
var sameDate = LocalDate.of(2026, 3, 1);
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", sameDate, sameDate);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
}
@Test
@DisplayName("should fail when productionDate is null")
void should_Fail_When_ProductionDateNull() {
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", null, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when bestBeforeDate is null")
void should_Fail_When_BestBeforeDateNull() {
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, null);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should accept decimal quantity")
void should_Accept_When_DecimalQuantity() {
var draft = new BatchDraft("recipe-123", "50.75", "LITER", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = Batch.plan(draft, BATCH_NUMBER);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().plannedQuantity().amount())
.isEqualByComparingTo(new BigDecimal("50.75"));
assertThat(result.unsafeGetValue().plannedQuantity().uom()).isEqualTo(UnitOfMeasure.LITER);
}
}
@Nested
@DisplayName("reconstitute()")
class Reconstitute {
@Test
@DisplayName("should reconstitute batch from persistence")
void should_Reconstitute_When_ValidData() {
var batch = Batch.reconstitute(
BatchId.of("batch-1"),
BATCH_NUMBER,
RecipeId.of("recipe-123"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
PRODUCTION_DATE,
BEST_BEFORE_DATE,
LocalDateTime.now(),
LocalDateTime.now()
);
assertThat(batch.id().value()).isEqualTo("batch-1");
assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
}
}
}

View file

@ -0,0 +1,328 @@
package de.effigenix.infrastructure.production.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("Batch Controller Integration Tests")
class BatchControllerIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
@BeforeEach
void setUp() throws Exception {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ");
}
@Nested
@DisplayName("POST /api/production/batches Charge planen")
class PlanBatchEndpoint {
@Test
@DisplayName("Charge planen mit gültigen Daten → 201")
void planBatch_withValidData_returns201() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.batchNumber").isNotEmpty())
.andExpect(jsonPath("$.recipeId").value(recipeId))
.andExpect(jsonPath("$.status").value("PLANNED"))
.andExpect(jsonPath("$.plannedQuantity").value("100.000000"))
.andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.productionDate").value("2026-03-01"))
.andExpect(jsonPath("$.bestBeforeDate").value("2026-06-01"))
.andExpect(jsonPath("$.createdAt").isNotEmpty())
.andExpect(jsonPath("$.updatedAt").isNotEmpty());
}
@Test
@DisplayName("BatchNumber hat Format P-YYYY-MM-DD-XXX")
void planBatch_batchNumberFormat() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"));
}
@Test
@DisplayName("Zweite Charge am selben Tag → inkrementierte Sequenznummer")
void planBatch_secondBatchSameDay_incrementsSequence() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"));
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-002"));
}
@Test
@DisplayName("PlannedQuantity 0 → 400")
void planBatch_zeroQuantity_returns400() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
}
@Test
@DisplayName("Negative Menge → 400")
void planBatch_negativeQuantity_returns400() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "-10", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
}
@Test
@DisplayName("Ungültige Unit → 400")
void planBatch_invalidUnit_returns400() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
}
@Test
@DisplayName("BestBeforeDate vor ProductionDate → 400")
void planBatch_bestBeforeBeforeProduction_returns400() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "100", "KILOGRAM",
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES"));
}
@Test
@DisplayName("BestBeforeDate gleich ProductionDate → 400")
void planBatch_sameDate_returns400() throws Exception {
String recipeId = createActiveRecipe();
var sameDate = LocalDate.of(2026, 3, 1);
var request = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", sameDate, sameDate);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES"));
}
@Test
@DisplayName("Rezept nicht gefunden → 400")
void planBatch_recipeNotFound_returns400() throws Exception {
var request = new PlanBatchRequest(
UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR"));
}
@Test
@DisplayName("Rezept nicht ACTIVE (DRAFT) → 409")
void planBatch_recipeNotActive_returns409() throws Exception {
String recipeId = createDraftRecipe();
var request = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_RECIPE_NOT_ACTIVE"));
}
@Test
@DisplayName("recipeId leer → 400 (Bean Validation)")
void planBatch_blankRecipeId_returns400() throws Exception {
var request = new PlanBatchRequest(
"", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("plannedQuantity leer → 400 (Bean Validation)")
void planBatch_blankQuantity_returns400() throws Exception {
var request = new PlanBatchRequest(
UUID.randomUUID().toString(), "", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
@Nested
@DisplayName("Authorization")
class AuthTests {
@Test
@DisplayName("Ohne BATCH_WRITE → 403")
void planBatch_withViewerToken_returns403() throws Exception {
var request = new PlanBatchRequest(
UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void planBatch_withoutToken_returns401() throws Exception {
var request = new PlanBatchRequest(
UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
mockMvc.perform(post("/api/production/batches")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createActiveRecipe() throws Exception {
String recipeId = createDraftRecipe();
// Add ingredient (required for activation)
String ingredientJson = """
{"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false}
""".formatted(UUID.randomUUID().toString());
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(ingredientJson))
.andExpect(status().isCreated());
// Activate
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
return recipeId;
}
private String createDraftRecipe() throws Exception {
String json = """
{
"name": "Test-Rezept-%s",
"version": 1,
"type": "FINISHED_PRODUCT",
"description": "Testrezept",
"yieldPercentage": 85,
"shelfLifeDays": 14,
"outputQuantity": "100",
"outputUom": "KILOGRAM"
}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/recipes")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
}