1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 13:49:36 +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,58 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.AddProductionStepCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class AddProductionStep {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public AddProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(AddProductionStepCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe;
switch (recipeRepository.findById(recipeId)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new RecipeError.RecipeNotFound(recipeId));
}
recipe = opt.get();
}
}
var draft = new ProductionStepDraft(
cmd.stepNumber(), cmd.description(),
cmd.durationMinutes(), cmd.temperatureCelsius()
);
switch (recipe.addProductionStep(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
switch (recipeRepository.save(recipe)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(recipe);
}
}

View file

@ -4,18 +4,25 @@ import de.effigenix.application.production.command.AddRecipeIngredientCommand;
import de.effigenix.domain.production.*; import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Transactional @Transactional
public class AddRecipeIngredient { public class AddRecipeIngredient {
private final RecipeRepository recipeRepository; private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public AddRecipeIngredient(RecipeRepository recipeRepository) { public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
} }
public Result<RecipeError, Recipe> execute(AddRecipeIngredientCommand cmd, ActorId performedBy) { public Result<RecipeError, Recipe> execute(AddRecipeIngredientCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId()); var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe; Recipe recipe;

View file

@ -4,18 +4,25 @@ import de.effigenix.application.production.command.CreateRecipeCommand;
import de.effigenix.domain.production.*; import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Transactional @Transactional
public class CreateRecipe { public class CreateRecipe {
private final RecipeRepository recipeRepository; private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public CreateRecipe(RecipeRepository recipeRepository) { public CreateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
} }
public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) { public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to create recipes"));
}
var draft = new RecipeDraft( var draft = new RecipeDraft(
cmd.name(), cmd.version(), cmd.type(), cmd.description(), cmd.name(), cmd.version(), cmd.type(), cmd.description(),
cmd.yieldPercentage(), cmd.shelfLifeDays(), cmd.yieldPercentage(), cmd.shelfLifeDays(),

View file

@ -0,0 +1,53 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.RemoveProductionStepCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class RemoveProductionStep {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public RemoveProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(RemoveProductionStepCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe;
switch (recipeRepository.findById(recipeId)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new RecipeError.RecipeNotFound(recipeId));
}
recipe = opt.get();
}
}
switch (recipe.removeProductionStep(cmd.stepNumber())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
switch (recipeRepository.save(recipe)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(recipe);
}
}

View file

@ -4,18 +4,25 @@ import de.effigenix.application.production.command.RemoveRecipeIngredientCommand
import de.effigenix.domain.production.*; import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Transactional @Transactional
public class RemoveRecipeIngredient { public class RemoveRecipeIngredient {
private final RecipeRepository recipeRepository; private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public RemoveRecipeIngredient(RecipeRepository recipeRepository) { public RemoveRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
} }
public Result<RecipeError, Recipe> execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) { public Result<RecipeError, Recipe> execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId()); var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe; Recipe recipe;

View file

@ -0,0 +1,9 @@
package de.effigenix.application.production.command;
public record AddProductionStepCommand(
String recipeId,
int stepNumber,
String description,
Integer durationMinutes,
Integer temperatureCelsius
) {}

View file

@ -0,0 +1,6 @@
package de.effigenix.application.production.command;
public record RemoveProductionStepCommand(
String recipeId,
int stepNumber
) {}

View file

@ -0,0 +1,80 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
/**
* Child entity of Recipe representing a single production step.
*
* Invariants:
* 1. StepNumber must be >= 1
* 2. Description must not be blank
* 3. DurationMinutes must be > 0 if set
* 4. TemperatureCelsius must be between -273 and 1000 if set
*/
public class ProductionStep {
private final ProductionStepId id;
private final int stepNumber;
private final String description;
private final Integer durationMinutes;
private final Integer temperatureCelsius;
private ProductionStep(ProductionStepId id, int stepNumber, String description,
Integer durationMinutes, Integer temperatureCelsius) {
this.id = id;
this.stepNumber = stepNumber;
this.description = description;
this.durationMinutes = durationMinutes;
this.temperatureCelsius = temperatureCelsius;
}
public static Result<RecipeError, ProductionStep> create(ProductionStepDraft draft) {
if (draft.stepNumber() < 1) {
return Result.failure(new RecipeError.ValidationFailure(
"Step number must be >= 1, was: " + draft.stepNumber()));
}
if (draft.description() == null || draft.description().isBlank()) {
return Result.failure(new RecipeError.ValidationFailure("Step description must not be blank"));
}
if (draft.durationMinutes() != null && draft.durationMinutes() <= 0) {
return Result.failure(new RecipeError.ValidationFailure(
"Duration minutes must be > 0, was: " + draft.durationMinutes()));
}
if (draft.temperatureCelsius() != null
&& (draft.temperatureCelsius() < -273 || draft.temperatureCelsius() > 1000)) {
return Result.failure(new RecipeError.ValidationFailure(
"Temperature must be between -273 and 1000 Celsius, was: " + draft.temperatureCelsius()));
}
return Result.success(new ProductionStep(
ProductionStepId.generate(), draft.stepNumber(), draft.description(),
draft.durationMinutes(), draft.temperatureCelsius()
));
}
public static ProductionStep reconstitute(ProductionStepId id, int stepNumber, String description,
Integer durationMinutes, Integer temperatureCelsius) {
return new ProductionStep(id, stepNumber, description, durationMinutes, temperatureCelsius);
}
public ProductionStepId id() { return id; }
public int stepNumber() { return stepNumber; }
public String description() { return description; }
public Integer durationMinutes() { return durationMinutes; }
public Integer temperatureCelsius() { return temperatureCelsius; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof ProductionStep other)) return false;
return id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}

View file

@ -0,0 +1,17 @@
package de.effigenix.domain.production;
/**
* Draft for adding a production step to a recipe. Application layer builds this from raw command inputs.
* The ProductionStep entity validates internally.
*
* @param stepNumber Step number in the recipe (required, >= 1)
* @param description Description of what to do in this step (required)
* @param durationMinutes Optional duration in minutes
* @param temperatureCelsius Optional temperature in Celsius
*/
public record ProductionStepDraft(
int stepNumber,
String description,
Integer durationMinutes,
Integer temperatureCelsius
) {}

View file

@ -0,0 +1,20 @@
package de.effigenix.domain.production;
import java.util.UUID;
public record ProductionStepId(String value) {
public ProductionStepId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("ProductionStepId must not be blank");
}
}
public static ProductionStepId generate() {
return new ProductionStepId(UUID.randomUUID().toString());
}
public static ProductionStepId of(String value) {
return new ProductionStepId(value);
}
}

View file

@ -23,6 +23,8 @@ import static de.effigenix.shared.common.Result.*;
* 6. New recipes always start in DRAFT status * 6. New recipes always start in DRAFT status
* 7. Ingredients can only be added/removed in DRAFT status * 7. Ingredients can only be added/removed in DRAFT status
* 8. Ingredient positions must be unique within a recipe * 8. Ingredient positions must be unique within a recipe
* 9. ProductionSteps can only be added/removed in DRAFT status
* 10. Step numbers must be unique within a recipe
*/ */
public class Recipe { public class Recipe {
@ -36,6 +38,7 @@ public class Recipe {
private Quantity outputQuantity; private Quantity outputQuantity;
private RecipeStatus status; private RecipeStatus status;
private final List<Ingredient> ingredients; private final List<Ingredient> ingredients;
private final List<ProductionStep> productionSteps;
private final LocalDateTime createdAt; private final LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@ -50,6 +53,7 @@ public class Recipe {
Quantity outputQuantity, Quantity outputQuantity,
RecipeStatus status, RecipeStatus status,
List<Ingredient> ingredients, List<Ingredient> ingredients,
List<ProductionStep> productionSteps,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
@ -63,6 +67,7 @@ public class Recipe {
this.outputQuantity = outputQuantity; this.outputQuantity = outputQuantity;
this.status = status; this.status = status;
this.ingredients = new ArrayList<>(ingredients); this.ingredients = new ArrayList<>(ingredients);
this.productionSteps = new ArrayList<>(productionSteps);
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
@ -117,7 +122,7 @@ public class Recipe {
return Result.success(new Recipe( return Result.success(new Recipe(
RecipeId.generate(), name, draft.version(), draft.type(), RecipeId.generate(), name, draft.version(), draft.type(),
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity, draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
RecipeStatus.DRAFT, List.of(), now, now RecipeStatus.DRAFT, List.of(), List.of(), now, now
)); ));
} }
@ -135,11 +140,12 @@ public class Recipe {
Quantity outputQuantity, Quantity outputQuantity,
RecipeStatus status, RecipeStatus status,
List<Ingredient> ingredients, List<Ingredient> ingredients,
List<ProductionStep> productionSteps,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
return new Recipe(id, name, version, type, description, return new Recipe(id, name, version, type, description,
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, createdAt, updatedAt); yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt);
} }
// ==================== Ingredient Management ==================== // ==================== Ingredient Management ====================
@ -175,6 +181,39 @@ public class Recipe {
return Result.success(null); return Result.success(null);
} }
// ==================== ProductionStep Management ====================
public Result<RecipeError, ProductionStep> addProductionStep(ProductionStepDraft draft) {
if (status != RecipeStatus.DRAFT) {
return Result.failure(new RecipeError.NotInDraftStatus());
}
if (hasStepNumber(draft.stepNumber())) {
return Result.failure(new RecipeError.DuplicateStepNumber(draft.stepNumber()));
}
ProductionStep step;
switch (ProductionStep.create(draft)) {
case Failure(var err) -> { return Result.failure(err); }
case Success(var val) -> step = val;
}
this.productionSteps.add(step);
touch();
return Result.success(step);
}
public Result<RecipeError, Void> removeProductionStep(int stepNumber) {
if (status != RecipeStatus.DRAFT) {
return Result.failure(new RecipeError.NotInDraftStatus());
}
boolean removed = this.productionSteps.removeIf(s -> s.stepNumber() == stepNumber);
if (!removed) {
return Result.failure(new RecipeError.StepNotFound(stepNumber));
}
touch();
return Result.success(null);
}
// ==================== Getters ==================== // ==================== Getters ====================
public RecipeId id() { return id; } public RecipeId id() { return id; }
@ -187,6 +226,7 @@ public class Recipe {
public Quantity outputQuantity() { return outputQuantity; } public Quantity outputQuantity() { return outputQuantity; }
public RecipeStatus status() { return status; } public RecipeStatus status() { return status; }
public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); } public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); }
public LocalDateTime createdAt() { return createdAt; } public LocalDateTime createdAt() { return createdAt; }
public LocalDateTime updatedAt() { return updatedAt; } public LocalDateTime updatedAt() { return updatedAt; }
@ -196,6 +236,10 @@ public class Recipe {
return ingredients.stream().anyMatch(i -> i.position() == position); return ingredients.stream().anyMatch(i -> i.position() == position);
} }
private boolean hasStepNumber(int stepNumber) {
return productionSteps.stream().anyMatch(s -> s.stepNumber() == stepNumber);
}
private void touch() { private void touch() {
this.updatedAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now();
} }

View file

@ -35,10 +35,20 @@ public sealed interface RecipeError {
} }
record IngredientNotFound(IngredientId id) implements RecipeError { record IngredientNotFound(IngredientId id) implements RecipeError {
@Override public String code() { return "INGREDIENT_NOT_FOUND"; } @Override public String code() { return "RECIPE_INGREDIENT_NOT_FOUND"; }
@Override public String message() { return "Ingredient with ID '" + id.value() + "' not found"; } @Override public String message() { return "Ingredient with ID '" + id.value() + "' not found"; }
} }
record DuplicateStepNumber(int stepNumber) implements RecipeError {
@Override public String code() { return "RECIPE_DUPLICATE_STEP_NUMBER"; }
@Override public String message() { return "Step number " + stepNumber + " is already taken"; }
}
record StepNotFound(int stepNumber) implements RecipeError {
@Override public String code() { return "RECIPE_STEP_NOT_FOUND"; }
@Override public String message() { return "Production step with number " + stepNumber + " not found"; }
}
record Unauthorized(String message) implements RecipeError { record Unauthorized(String message) implements RecipeError {
@Override public String code() { return "UNAUTHORIZED"; } @Override public String code() { return "UNAUTHORIZED"; }
} }

View file

@ -1,9 +1,12 @@
package de.effigenix.infrastructure.config; package de.effigenix.infrastructure.config;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient; import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.domain.production.RecipeRepository; import de.effigenix.domain.production.RecipeRepository;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -11,17 +14,27 @@ import org.springframework.context.annotation.Configuration;
public class ProductionUseCaseConfiguration { public class ProductionUseCaseConfiguration {
@Bean @Bean
public CreateRecipe createRecipe(RecipeRepository recipeRepository) { public CreateRecipe createRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new CreateRecipe(recipeRepository); return new CreateRecipe(recipeRepository, authorizationPort);
} }
@Bean @Bean
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository) { public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new AddRecipeIngredient(recipeRepository); return new AddRecipeIngredient(recipeRepository, authorizationPort);
} }
@Bean @Bean
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository) { public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new RemoveRecipeIngredient(recipeRepository); return new RemoveRecipeIngredient(recipeRepository, authorizationPort);
}
@Bean
public AddProductionStep addProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new AddProductionStep(recipeRepository, authorizationPort);
}
@Bean
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new RemoveProductionStep(recipeRepository, authorizationPort);
} }
} }

View file

@ -0,0 +1,55 @@
package de.effigenix.infrastructure.production.persistence.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "recipe_production_steps",
uniqueConstraints = @UniqueConstraint(name = "uq_recipe_step_number", columnNames = {"recipe_id", "step_number"}))
public class ProductionStepEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id", nullable = false)
private RecipeEntity recipe;
@Column(name = "step_number", nullable = false)
private int stepNumber;
@Column(name = "description", nullable = false, length = 500)
private String description;
@Column(name = "duration_minutes")
private Integer durationMinutes;
@Column(name = "temperature_celsius")
private Integer temperatureCelsius;
protected ProductionStepEntity() {}
public ProductionStepEntity(String id, RecipeEntity recipe, int stepNumber, String description,
Integer durationMinutes, Integer temperatureCelsius) {
this.id = id;
this.recipe = recipe;
this.stepNumber = stepNumber;
this.description = description;
this.durationMinutes = durationMinutes;
this.temperatureCelsius = temperatureCelsius;
}
public String getId() { return id; }
public RecipeEntity getRecipe() { return recipe; }
public int getStepNumber() { return stepNumber; }
public String getDescription() { return description; }
public Integer getDurationMinutes() { return durationMinutes; }
public Integer getTemperatureCelsius() { return temperatureCelsius; }
public void setId(String id) { this.id = id; }
public void setRecipe(RecipeEntity recipe) { this.recipe = recipe; }
public void setStepNumber(int stepNumber) { this.stepNumber = stepNumber; }
public void setDescription(String description) { this.description = description; }
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
public void setTemperatureCelsius(Integer temperatureCelsius) { this.temperatureCelsius = temperatureCelsius; }
}

View file

@ -53,6 +53,10 @@ public class RecipeEntity {
@OrderBy("position ASC") @OrderBy("position ASC")
private List<IngredientEntity> ingredients = new ArrayList<>(); private List<IngredientEntity> ingredients = new ArrayList<>();
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@OrderBy("stepNumber ASC")
private List<ProductionStepEntity> productionSteps = new ArrayList<>();
protected RecipeEntity() {} protected RecipeEntity() {}
public RecipeEntity(String id, String name, int version, String type, String description, public RecipeEntity(String id, String name, int version, String type, String description,
@ -85,6 +89,7 @@ public class RecipeEntity {
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; } public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<IngredientEntity> getIngredients() { return ingredients; } public List<IngredientEntity> getIngredients() { return ingredients; }
public List<ProductionStepEntity> getProductionSteps() { return productionSteps; }
public void setId(String id) { this.id = id; } public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; } public void setName(String name) { this.name = name; }
@ -99,4 +104,5 @@ public class RecipeEntity {
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public void setIngredients(List<IngredientEntity> ingredients) { this.ingredients = ingredients; } public void setIngredients(List<IngredientEntity> ingredients) { this.ingredients = ingredients; }
public void setProductionSteps(List<ProductionStepEntity> productionSteps) { this.productionSteps = productionSteps; }
} }

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.production.persistence.mapper;
import de.effigenix.domain.production.*; import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity; import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -32,6 +33,11 @@ public class RecipeMapper {
.collect(Collectors.toList()); .collect(Collectors.toList());
entity.setIngredients(ingredientEntities); entity.setIngredients(ingredientEntities);
List<ProductionStepEntity> stepEntities = recipe.productionSteps().stream()
.map(s -> toProductionStepEntity(s, entity))
.collect(Collectors.toList());
entity.setProductionSteps(stepEntities);
return entity; return entity;
} }
@ -40,6 +46,10 @@ public class RecipeMapper {
.map(this::toDomainIngredient) .map(this::toDomainIngredient)
.collect(Collectors.toList()); .collect(Collectors.toList());
List<ProductionStep> productionSteps = entity.getProductionSteps().stream()
.map(this::toDomainProductionStep)
.collect(Collectors.toList());
return Recipe.reconstitute( return Recipe.reconstitute(
RecipeId.of(entity.getId()), RecipeId.of(entity.getId()),
new RecipeName(entity.getName()), new RecipeName(entity.getName()),
@ -55,6 +65,7 @@ public class RecipeMapper {
), ),
RecipeStatus.valueOf(entity.getStatus()), RecipeStatus.valueOf(entity.getStatus()),
ingredients, ingredients,
productionSteps,
entity.getCreatedAt(), entity.getCreatedAt(),
entity.getUpdatedAt() entity.getUpdatedAt()
); );
@ -73,6 +84,27 @@ public class RecipeMapper {
); );
} }
private ProductionStepEntity toProductionStepEntity(ProductionStep step, RecipeEntity recipe) {
return new ProductionStepEntity(
step.id().value(),
recipe,
step.stepNumber(),
step.description(),
step.durationMinutes(),
step.temperatureCelsius()
);
}
private ProductionStep toDomainProductionStep(ProductionStepEntity entity) {
return ProductionStep.reconstitute(
ProductionStepId.of(entity.getId()),
entity.getStepNumber(),
entity.getDescription(),
entity.getDurationMinutes(),
entity.getTemperatureCelsius()
);
}
private Ingredient toDomainIngredient(IngredientEntity entity) { private Ingredient toDomainIngredient(IngredientEntity entity) {
return Ingredient.reconstitute( return Ingredient.reconstitute(
IngredientId.of(entity.getId()), IngredientId.of(entity.getId()),

View file

@ -1,12 +1,17 @@
package de.effigenix.infrastructure.production.web.controller; package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient; import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.application.production.command.AddProductionStepCommand;
import de.effigenix.application.production.command.AddRecipeIngredientCommand; import de.effigenix.application.production.command.AddRecipeIngredientCommand;
import de.effigenix.application.production.command.CreateRecipeCommand; import de.effigenix.application.production.command.CreateRecipeCommand;
import de.effigenix.application.production.command.RemoveProductionStepCommand;
import de.effigenix.application.production.command.RemoveRecipeIngredientCommand; import de.effigenix.application.production.command.RemoveRecipeIngredientCommand;
import de.effigenix.domain.production.RecipeError; import de.effigenix.domain.production.RecipeError;
import de.effigenix.infrastructure.production.web.dto.AddProductionStepRequest;
import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest; import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest;
import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest; import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest;
import de.effigenix.infrastructure.production.web.dto.RecipeResponse; import de.effigenix.infrastructure.production.web.dto.RecipeResponse;
@ -33,13 +38,19 @@ public class RecipeController {
private final CreateRecipe createRecipe; private final CreateRecipe createRecipe;
private final AddRecipeIngredient addRecipeIngredient; private final AddRecipeIngredient addRecipeIngredient;
private final RemoveRecipeIngredient removeRecipeIngredient; private final RemoveRecipeIngredient removeRecipeIngredient;
private final AddProductionStep addProductionStep;
private final RemoveProductionStep removeProductionStep;
public RecipeController(CreateRecipe createRecipe, public RecipeController(CreateRecipe createRecipe,
AddRecipeIngredient addRecipeIngredient, AddRecipeIngredient addRecipeIngredient,
RemoveRecipeIngredient removeRecipeIngredient) { RemoveRecipeIngredient removeRecipeIngredient,
AddProductionStep addProductionStep,
RemoveProductionStep removeProductionStep) {
this.createRecipe = createRecipe; this.createRecipe = createRecipe;
this.addRecipeIngredient = addRecipeIngredient; this.addRecipeIngredient = addRecipeIngredient;
this.removeRecipeIngredient = removeRecipeIngredient; this.removeRecipeIngredient = removeRecipeIngredient;
this.addProductionStep = addProductionStep;
this.removeProductionStep = removeProductionStep;
} }
@PostMapping @PostMapping
@ -111,6 +122,51 @@ public class RecipeController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PostMapping("/{id}/steps")
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<RecipeResponse> addProductionStep(
@PathVariable("id") String recipeId,
@Valid @RequestBody AddProductionStepRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Adding production step to recipe: {} by actor: {}", recipeId, actorId.value());
var cmd = new AddProductionStepCommand(
recipeId, request.stepNumber(), request.description(),
request.durationMinutes(), request.temperatureCelsius()
);
var result = addProductionStep.execute(cmd, actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
logger.info("Production step added to recipe: {}", recipeId);
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue()));
}
@DeleteMapping("/{id}/steps/{stepNumber}")
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<Void> removeProductionStep(
@PathVariable("id") String recipeId,
@PathVariable("stepNumber") int stepNumber,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Removing production step {} from recipe: {} by actor: {}", stepNumber, recipeId, actorId.value());
var cmd = new RemoveProductionStepCommand(recipeId, stepNumber);
var result = removeProductionStep.execute(cmd, actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
logger.info("Production step removed from recipe: {}", recipeId);
return ResponseEntity.noContent().build();
}
private ActorId extractActorId(Authentication authentication) { private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) { if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext"); throw new IllegalStateException("No authentication found in SecurityContext");

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.production.web.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AddProductionStepRequest(
@Min(1) int stepNumber,
@NotBlank @Size(max = 500) String description,
@Min(1) Integer durationMinutes,
@Min(-273) @Max(1000) Integer temperatureCelsius
) {}

View file

@ -0,0 +1,23 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.ProductionStep;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"id", "stepNumber", "description"})
public record ProductionStepResponse(
String id,
int stepNumber,
String description,
@Schema(nullable = true) Integer durationMinutes,
@Schema(nullable = true) Integer temperatureCelsius
) {
public static ProductionStepResponse from(ProductionStep step) {
return new ProductionStepResponse(
step.id().value(),
step.stepNumber(),
step.description(),
step.durationMinutes(),
step.temperatureCelsius()
);
}
}

View file

@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "createdAt", "updatedAt"}) @Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"})
public record RecipeResponse( public record RecipeResponse(
String id, String id,
String name, String name,
@ -19,6 +19,7 @@ public record RecipeResponse(
String outputUom, String outputUom,
String status, String status,
List<IngredientResponse> ingredients, List<IngredientResponse> ingredients,
List<ProductionStepResponse> productionSteps,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
@ -35,6 +36,7 @@ public record RecipeResponse(
recipe.outputQuantity().uom().name(), recipe.outputQuantity().uom().name(),
recipe.status().name(), recipe.status().name(),
recipe.ingredients().stream().map(IngredientResponse::from).toList(), recipe.ingredients().stream().map(IngredientResponse::from).toList(),
recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(),
recipe.createdAt(), recipe.createdAt(),
recipe.updatedAt() recipe.updatedAt()
); );

View file

@ -10,8 +10,10 @@ public final class ProductionErrorHttpStatusMapper {
return switch (error) { return switch (error) {
case RecipeError.RecipeNotFound e -> 404; case RecipeError.RecipeNotFound e -> 404;
case RecipeError.IngredientNotFound e -> 404; case RecipeError.IngredientNotFound e -> 404;
case RecipeError.StepNotFound e -> 404;
case RecipeError.NameAndVersionAlreadyExists e -> 409; case RecipeError.NameAndVersionAlreadyExists e -> 409;
case RecipeError.DuplicatePosition e -> 409; case RecipeError.DuplicatePosition e -> 409;
case RecipeError.DuplicateStepNumber e -> 409;
case RecipeError.InvalidShelfLife e -> 400; case RecipeError.InvalidShelfLife e -> 400;
case RecipeError.ValidationFailure e -> 400; case RecipeError.ValidationFailure e -> 400;
case RecipeError.NotInDraftStatus e -> 409; case RecipeError.NotInDraftStatus e -> 409;

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="012-create-recipe-production-steps-table" author="effigenix">
<createTable tableName="recipe_production_steps">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="recipe_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="step_number" type="INT">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="duration_minutes" type="INT"/>
<column name="temperature_celsius" type="INT"/>
</createTable>
<addForeignKeyConstraint baseTableName="recipe_production_steps" baseColumnNames="recipe_id"
referencedTableName="recipes" referencedColumnNames="id"
constraintName="fk_recipe_production_steps_recipe"
onDelete="CASCADE"/>
<addUniqueConstraint tableName="recipe_production_steps" columnNames="recipe_id, step_number"
constraintName="uq_recipe_step_number"/>
<createIndex tableName="recipe_production_steps" indexName="idx_recipe_production_steps_recipe_id">
<column name="recipe_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -16,5 +16,6 @@
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/> <include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
<include file="db/changelog/changes/010-create-recipe-schema.xml"/> <include file="db/changelog/changes/010-create-recipe-schema.xml"/>
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/> <include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
</databaseChangeLog> </databaseChangeLog>

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.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT); assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT);
assertThat(recipe.ingredients()).isEmpty(); assertThat(recipe.ingredients()).isEmpty();
assertThat(recipe.productionSteps()).isEmpty();
assertThat(recipe.createdAt()).isNotNull(); assertThat(recipe.createdAt()).isNotNull();
assertThat(recipe.updatedAt()).isNotNull(); assertThat(recipe.updatedAt()).isNotNull();
} }
@ -235,7 +236,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), 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() java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
); );
@ -318,7 +319,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), 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() 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 @Nested
@DisplayName("Equality") @DisplayName("Equality")
class Equality { class Equality {
@ -356,7 +480,7 @@ class RecipeTest {
recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL, recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL,
null, new YieldPercentage(100), null, null, new YieldPercentage(100), null,
Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), 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); assertThat(recipe1).isEqualTo(recipe2);

File diff suppressed because one or more lines are too long

View file

@ -356,6 +356,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/recipes/{id}/steps": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["addProductionStep"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/recipes/{id}/ingredients": { "/api/recipes/{id}/ingredients": {
parameters: { parameters: {
query?: never; query?: never;
@ -680,6 +696,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/recipes/{id}/steps/{stepNumber}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["removeProductionStep"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/recipes/{id}/ingredients/{ingredientId}": { "/api/recipes/{id}/ingredients/{ingredientId}": {
parameters: { parameters: {
query?: never; query?: never;
@ -1070,6 +1102,16 @@ export interface components {
subRecipeId?: string | null; subRecipeId?: string | null;
substitutable: boolean; substitutable: boolean;
}; };
ProductionStepResponse: {
id: string;
/** Format: int32 */
stepNumber: number;
description: string;
/** Format: int32 */
durationMinutes?: number | null;
/** Format: int32 */
temperatureCelsius?: number | null;
};
RecipeResponse: { RecipeResponse: {
id: string; id: string;
name: string; name: string;
@ -1085,11 +1127,21 @@ export interface components {
outputUom: string; outputUom: string;
status: string; status: string;
ingredients: components["schemas"]["IngredientResponse"][]; ingredients: components["schemas"]["IngredientResponse"][];
productionSteps: components["schemas"]["ProductionStepResponse"][];
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
}; };
AddProductionStepRequest: {
/** Format: int32 */
stepNumber?: number;
description: string;
/** Format: int32 */
durationMinutes?: number;
/** Format: int32 */
temperatureCelsius?: number;
};
AddRecipeIngredientRequest: { AddRecipeIngredientRequest: {
/** Format: int32 */ /** Format: int32 */
position?: number; position?: number;
@ -2076,6 +2128,32 @@ export interface operations {
}; };
}; };
}; };
addProductionStep: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AddProductionStepRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RecipeResponse"];
};
};
};
};
addIngredient: { addIngredient: {
parameters: { parameters: {
query?: never; query?: never;
@ -2672,6 +2750,27 @@ export interface operations {
}; };
}; };
}; };
removeProductionStep: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
stepNumber: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeIngredient: { removeIngredient: {
parameters: { parameters: {
query?: never; query?: never;