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

feat(production): Produktionsschritte zum Rezept verwalten + AuthorizationPort

Erweitert das Recipe-Aggregate um ProductionStep-Child-Entities (Add/Remove)
mit vollständiger DDD-Konformität. Führt AuthorizationPort-Prüfung in allen
Production Use Cases ein (analog zum usermanagement-Referenz-BC).

Fixes: Request-Validierung (Size, Min/Max), Error-Code-Konsistenz,
Defense-in-Depth für durationMinutes und temperatureCelsius.
This commit is contained in:
Sebastian Frick 2026-02-19 17:37:18 +01:00
parent c26d72fbe7
commit cf93b847e5
27 changed files with 978 additions and 18 deletions

View file

@ -0,0 +1,177 @@
package de.effigenix.domain.production;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("ProductionStep Entity")
class ProductionStepTest {
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create production step with valid inputs")
void should_CreateStep_When_ValidInputs() {
var draft = new ProductionStepDraft(1, "Fleisch wolfen", 15, 4);
var result = ProductionStep.create(draft);
assertThat(result.isSuccess()).isTrue();
var step = result.unsafeGetValue();
assertThat(step.id()).isNotNull();
assertThat(step.stepNumber()).isEqualTo(1);
assertThat(step.description()).isEqualTo("Fleisch wolfen");
assertThat(step.durationMinutes()).isEqualTo(15);
assertThat(step.temperatureCelsius()).isEqualTo(4);
}
@Test
@DisplayName("should create production step with nullable fields as null")
void should_CreateStep_When_NullableFieldsAreNull() {
var draft = new ProductionStepDraft(1, "Mischen", null, null);
var result = ProductionStep.create(draft);
assertThat(result.isSuccess()).isTrue();
var step = result.unsafeGetValue();
assertThat(step.durationMinutes()).isNull();
assertThat(step.temperatureCelsius()).isNull();
}
@Test
@DisplayName("should fail when step number is 0")
void should_Fail_When_StepNumberIsZero() {
var draft = new ProductionStepDraft(0, "Mischen", null, null);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when step number is negative")
void should_Fail_When_StepNumberIsNegative() {
var draft = new ProductionStepDraft(-1, "Mischen", null, null);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when description is blank")
void should_Fail_When_DescriptionIsBlank() {
var draft = new ProductionStepDraft(1, "", null, null);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when description is null")
void should_Fail_When_DescriptionIsNull() {
var draft = new ProductionStepDraft(1, null, null, null);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when duration minutes is zero")
void should_Fail_When_DurationMinutesIsZero() {
var draft = new ProductionStepDraft(1, "Mischen", 0, null);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when duration minutes is negative")
void should_Fail_When_DurationMinutesIsNegative() {
var draft = new ProductionStepDraft(1, "Mischen", -5, null);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when temperature is below absolute zero")
void should_Fail_When_TemperatureBelowAbsoluteZero() {
var draft = new ProductionStepDraft(1, "Mischen", null, -274);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when temperature is above 1000")
void should_Fail_When_TemperatureAbove1000() {
var draft = new ProductionStepDraft(1, "Mischen", null, 1001);
var result = ProductionStep.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should allow negative temperature within valid range")
void should_AllowNegativeTemperature_WhenInRange() {
var draft = new ProductionStepDraft(1, "Tiefkühlen", null, -18);
var result = ProductionStep.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().temperatureCelsius()).isEqualTo(-18);
}
}
@Nested
@DisplayName("Equality")
class Equality {
@Test
@DisplayName("should be equal when same ID")
void should_BeEqual_When_SameId() {
var step = ProductionStep.create(
new ProductionStepDraft(1, "Mischen", 10, null)
).unsafeGetValue();
var reconstituted = ProductionStep.reconstitute(
step.id(), 2, "Other description", 20, 80
);
assertThat(step).isEqualTo(reconstituted);
assertThat(step.hashCode()).isEqualTo(reconstituted.hashCode());
}
@Test
@DisplayName("should not be equal when different ID")
void should_NotBeEqual_When_DifferentId() {
var step1 = ProductionStep.create(
new ProductionStepDraft(1, "Mischen", null, null)
).unsafeGetValue();
var step2 = ProductionStep.create(
new ProductionStepDraft(1, "Mischen", null, null)
).unsafeGetValue();
assertThat(step1).isNotEqualTo(step2);
}
}
}

View file

@ -45,6 +45,7 @@ class RecipeTest {
assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT);
assertThat(recipe.ingredients()).isEmpty();
assertThat(recipe.productionSteps()).isEmpty();
assertThat(recipe.createdAt()).isNotNull();
assertThat(recipe.updatedAt()).isNotNull();
}
@ -235,7 +236,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(),
RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
@ -318,7 +319,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(),
RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
@ -344,6 +345,129 @@ class RecipeTest {
}
}
@Nested
@DisplayName("addProductionStep()")
class AddProductionStep {
@Test
@DisplayName("should add production step to DRAFT recipe")
void should_AddStep_When_DraftStatus() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var result = recipe.addProductionStep(new ProductionStepDraft(1, "Fleisch wolfen", 15, 4));
assertThat(result.isSuccess()).isTrue();
var step = result.unsafeGetValue();
assertThat(step.id()).isNotNull();
assertThat(step.stepNumber()).isEqualTo(1);
assertThat(step.description()).isEqualTo("Fleisch wolfen");
assertThat(step.durationMinutes()).isEqualTo(15);
assertThat(step.temperatureCelsius()).isEqualTo(4);
assertThat(recipe.productionSteps()).hasSize(1);
}
@Test
@DisplayName("should fail when recipe is ACTIVE")
void should_Fail_When_ActiveStatus() {
var recipe = Recipe.reconstitute(
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
var result = recipe.addProductionStep(new ProductionStepDraft(1, "Mischen", null, null));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class);
}
@Test
@DisplayName("should fail when step number is duplicate")
void should_Fail_When_DuplicateStepNumber() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addProductionStep(new ProductionStepDraft(1, "Fleisch wolfen", null, null));
var result = recipe.addProductionStep(new ProductionStepDraft(1, "Nochmal wolfen", null, null));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.DuplicateStepNumber.class);
}
@Test
@DisplayName("should allow multiple steps with different numbers")
void should_AllowMultipleSteps_WithDifferentNumbers() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addProductionStep(new ProductionStepDraft(1, "Wolfen", 15, null));
recipe.addProductionStep(new ProductionStepDraft(2, "Mischen", 10, null));
recipe.addProductionStep(new ProductionStepDraft(3, "Füllen", 20, null));
assertThat(recipe.productionSteps()).hasSize(3);
}
}
@Nested
@DisplayName("removeProductionStep()")
class RemoveProductionStep {
@Test
@DisplayName("should remove existing step by step number")
void should_RemoveStep_When_Exists() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addProductionStep(new ProductionStepDraft(1, "Wolfen", null, null));
var result = recipe.removeProductionStep(1);
assertThat(result.isSuccess()).isTrue();
assertThat(recipe.productionSteps()).isEmpty();
}
@Test
@DisplayName("should fail when step not found")
void should_Fail_When_StepNotFound() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var result = recipe.removeProductionStep(99);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.StepNotFound.class);
}
@Test
@DisplayName("should fail when recipe is not in DRAFT status")
void should_Fail_When_NotDraftStatus() {
var recipe = Recipe.reconstitute(
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
var result = recipe.removeProductionStep(1);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class);
}
@Test
@DisplayName("should allow gaps in step numbers after removal")
void should_AllowStepNumberGaps_AfterRemoval() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addProductionStep(new ProductionStepDraft(1, "Wolfen", null, null));
recipe.addProductionStep(new ProductionStepDraft(2, "Mischen", null, null));
recipe.addProductionStep(new ProductionStepDraft(3, "Füllen", null, null));
recipe.removeProductionStep(2);
assertThat(recipe.productionSteps()).hasSize(2);
assertThat(recipe.productionSteps().stream().map(s -> s.stepNumber()).toList())
.containsExactly(1, 3);
}
}
@Nested
@DisplayName("Equality")
class Equality {
@ -356,7 +480,7 @@ class RecipeTest {
recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL,
null, new YieldPercentage(100), null,
Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), recipe1.createdAt(), recipe1.updatedAt()
RecipeStatus.ACTIVE, List.of(), List.of(), recipe1.createdAt(), recipe1.updatedAt()
);
assertThat(recipe1).isEqualTo(recipe2);