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

feat(production): Zutaten zum Rezept verwalten (#27)

Ingredient als Child Entity des Recipe Aggregates implementiert.
Folgt dem Article → SalesUnit Pattern als Full Vertical Slice.

Domain: IngredientId, Ingredient, IngredientDraft, Recipe.addIngredient/removeIngredient
Application: AddRecipeIngredient, RemoveRecipeIngredient Use Cases
Infrastructure: IngredientEntity (JPA), REST-Endpoints (POST/DELETE), Liquibase Migration
Tests: RecipeTest (9 neue), IngredientTest (11 Tests)
This commit is contained in:
Sebastian Frick 2026-02-19 12:52:24 +01:00
parent dcaa43dc2c
commit bee3f28b5f
22 changed files with 912 additions and 5 deletions

View file

@ -0,0 +1,168 @@
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("Ingredient Entity")
class IngredientTest {
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create ingredient with valid inputs")
void should_CreateIngredient_When_ValidInputs() {
var draft = new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isSuccess()).isTrue();
var ingredient = result.unsafeGetValue();
assertThat(ingredient.id()).isNotNull();
assertThat(ingredient.position()).isEqualTo(1);
assertThat(ingredient.articleId()).isEqualTo("article-123");
assertThat(ingredient.quantity().amount()).isEqualByComparingTo("5.5");
assertThat(ingredient.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(ingredient.subRecipeId()).isNull();
assertThat(ingredient.substitutable()).isFalse();
}
@Test
@DisplayName("should fail when position is 0")
void should_Fail_When_PositionIsZero() {
var draft = new IngredientDraft(0, "article-123", "5", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when position is negative")
void should_Fail_When_PositionIsNegative() {
var draft = new IngredientDraft(-1, "article-123", "5", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when articleId is blank")
void should_Fail_When_ArticleIdIsBlank() {
var draft = new IngredientDraft(1, "", "5", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when articleId is null")
void should_Fail_When_ArticleIdIsNull() {
var draft = new IngredientDraft(1, null, "5", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when quantity is zero")
void should_Fail_When_QuantityIsZero() {
var draft = new IngredientDraft(1, "article-123", "0", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when quantity is negative")
void should_Fail_When_QuantityIsNegative() {
var draft = new IngredientDraft(1, "article-123", "-5", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when UoM is invalid")
void should_Fail_When_UomIsInvalid() {
var draft = new IngredientDraft(1, "article-123", "5", "INVALID", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when quantity is not a number")
void should_Fail_When_QuantityIsNotANumber() {
var draft = new IngredientDraft(1, "article-123", "abc", "KILOGRAM", null, false);
var result = Ingredient.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should create ingredient with subRecipeId")
void should_CreateIngredient_WithSubRecipeId() {
var draft = new IngredientDraft(1, "article-123", "10", "GRAM", "sub-recipe-456", true);
var result = Ingredient.create(draft);
assertThat(result.isSuccess()).isTrue();
var ingredient = result.unsafeGetValue();
assertThat(ingredient.subRecipeId()).isEqualTo("sub-recipe-456");
assertThat(ingredient.substitutable()).isTrue();
}
}
@Nested
@DisplayName("Equality")
class Equality {
@Test
@DisplayName("should be equal when same ID")
void should_BeEqual_When_SameId() {
var ingredient = Ingredient.create(
new IngredientDraft(1, "article-123", "5", "KILOGRAM", null, false)
).unsafeGetValue();
var reconstituted = Ingredient.reconstitute(
ingredient.id(), 2, "other-article", ingredient.quantity(), "sub", true
);
assertThat(ingredient).isEqualTo(reconstituted);
assertThat(ingredient.hashCode()).isEqualTo(reconstituted.hashCode());
}
@Test
@DisplayName("should not be equal when different ID")
void should_NotBeEqual_When_DifferentId() {
var ingredient1 = Ingredient.create(
new IngredientDraft(1, "article-123", "5", "KILOGRAM", null, false)
).unsafeGetValue();
var ingredient2 = Ingredient.create(
new IngredientDraft(1, "article-123", "5", "KILOGRAM", null, false)
).unsafeGetValue();
assertThat(ingredient1).isNotEqualTo(ingredient2);
}
}
}

View file

@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Recipe Aggregate")
@ -17,6 +19,10 @@ class RecipeTest {
);
}
private IngredientDraft validIngredientDraft(int position) {
return new IngredientDraft(position, "article-123", "5.5", "KILOGRAM", null, false);
}
@Nested
@DisplayName("create()")
class Create {
@ -38,6 +44,7 @@ class RecipeTest {
assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100");
assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT);
assertThat(recipe.ingredients()).isEmpty();
assertThat(recipe.createdAt()).isNotNull();
assertThat(recipe.updatedAt()).isNotNull();
}
@ -199,6 +206,144 @@ class RecipeTest {
}
}
@Nested
@DisplayName("addIngredient()")
class AddIngredient {
@Test
@DisplayName("should add ingredient to DRAFT recipe")
void should_AddIngredient_When_DraftStatus() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var result = recipe.addIngredient(validIngredientDraft(1));
assertThat(result.isSuccess()).isTrue();
var ingredient = result.unsafeGetValue();
assertThat(ingredient.id()).isNotNull();
assertThat(ingredient.position()).isEqualTo(1);
assertThat(ingredient.articleId()).isEqualTo("article-123");
assertThat(ingredient.quantity().amount()).isEqualByComparingTo("5.5");
assertThat(ingredient.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(ingredient.substitutable()).isFalse();
assertThat(recipe.ingredients()).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(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
var result = recipe.addIngredient(validIngredientDraft(1));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class);
}
@Test
@DisplayName("should fail when position is duplicate")
void should_Fail_When_DuplicatePosition() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addIngredient(validIngredientDraft(1));
var result = recipe.addIngredient(new IngredientDraft(1, "article-456", "3", "GRAM", null, false));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.DuplicatePosition.class);
}
@Test
@DisplayName("should allow ingredient with subRecipeId")
void should_AllowIngredient_WithSubRecipeId() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var draft = new IngredientDraft(1, "article-123", "10", "KILOGRAM", "sub-recipe-id", true);
var result = recipe.addIngredient(draft);
assertThat(result.isSuccess()).isTrue();
var ingredient = result.unsafeGetValue();
assertThat(ingredient.subRecipeId()).isEqualTo("sub-recipe-id");
assertThat(ingredient.substitutable()).isTrue();
}
@Test
@DisplayName("should allow multiple ingredients with different positions")
void should_AllowMultipleIngredients_WithDifferentPositions() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addIngredient(validIngredientDraft(1));
recipe.addIngredient(validIngredientDraft(2));
recipe.addIngredient(validIngredientDraft(3));
assertThat(recipe.ingredients()).hasSize(3);
}
}
@Nested
@DisplayName("removeIngredient()")
class RemoveIngredient {
@Test
@DisplayName("should remove existing ingredient")
void should_RemoveIngredient_When_Exists() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var ingredient = recipe.addIngredient(validIngredientDraft(1)).unsafeGetValue();
var result = recipe.removeIngredient(ingredient.id());
assertThat(result.isSuccess()).isTrue();
assertThat(recipe.ingredients()).isEmpty();
}
@Test
@DisplayName("should fail when ingredient not found")
void should_Fail_When_IngredientNotFound() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var result = recipe.removeIngredient(IngredientId.generate());
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.IngredientNotFound.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(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
var result = recipe.removeIngredient(IngredientId.generate());
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class);
}
@Test
@DisplayName("should allow gaps in positions after removal")
void should_AllowPositionGaps_AfterRemoval() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addIngredient(validIngredientDraft(1));
var ingredient2 = recipe.addIngredient(validIngredientDraft(2)).unsafeGetValue();
recipe.addIngredient(validIngredientDraft(3));
recipe.removeIngredient(ingredient2.id());
assertThat(recipe.ingredients()).hasSize(2);
assertThat(recipe.ingredients().stream().map(i -> i.position()).toList())
.containsExactly(1, 3);
}
}
@Nested
@DisplayName("Equality")
class Equality {
@ -211,7 +356,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, recipe1.createdAt(), recipe1.updatedAt()
RecipeStatus.ACTIVE, List.of(), recipe1.createdAt(), recipe1.updatedAt()
);
assertThat(recipe1).isEqualTo(recipe2);