mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
feat(production): Recipe Aggregate als Full Vertical Slice (#26)
Domain: Recipe Aggregate Root mit create(RecipeDraft), RecipeId, RecipeName, RecipeType, RecipeStatus, RecipeError, RecipeRepository Interface. Application: CreateRecipe Use Case mit Name+Version Uniqueness-Check. Infrastructure: JPA Entity/Mapper/Repository, REST POST /api/recipes, Liquibase Migration, ProductionErrorHttpStatusMapper, Spring Config. Tests: 15 Unit Tests für Recipe Aggregate (75 total im Production BC).
This commit is contained in:
parent
24a6869faf
commit
9b9b7311d1
23 changed files with 1095 additions and 0 deletions
|
|
@ -0,0 +1,50 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class CreateRecipe {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
|
||||
public CreateRecipe(RecipeRepository recipeRepository) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) {
|
||||
var draft = new RecipeDraft(
|
||||
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
|
||||
cmd.yieldPercentage(), cmd.shelfLifeDays(),
|
||||
cmd.outputQuantity(), cmd.outputUom()
|
||||
);
|
||||
|
||||
Recipe recipe;
|
||||
switch (Recipe.create(draft)) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> recipe = val;
|
||||
}
|
||||
|
||||
// Uniqueness check: Name + Version
|
||||
switch (recipeRepository.existsByNameAndVersion(cmd.name(), cmd.version())) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var exists) -> {
|
||||
if (exists) {
|
||||
return Result.failure(new RecipeError.NameAndVersionAlreadyExists(cmd.name(), cmd.version()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
import de.effigenix.domain.production.RecipeType;
|
||||
|
||||
public record CreateRecipeCommand(
|
||||
String name,
|
||||
int version,
|
||||
RecipeType type,
|
||||
String description,
|
||||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom
|
||||
) {}
|
||||
172
backend/src/main/java/de/effigenix/domain/production/Recipe.java
Normal file
172
backend/src/main/java/de/effigenix/domain/production/Recipe.java
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Recipe aggregate root.
|
||||
*
|
||||
* Invariants:
|
||||
* 1. Name must not be blank
|
||||
* 2. Version >= 1
|
||||
* 3. YieldPercentage 1-200%
|
||||
* 4. ShelfLifeDays > 0 for FINISHED_PRODUCT and INTERMEDIATE
|
||||
* 5. OutputQuantity must be positive
|
||||
* 6. New recipes always start in DRAFT status
|
||||
*/
|
||||
public class Recipe {
|
||||
|
||||
private final RecipeId id;
|
||||
private RecipeName name;
|
||||
private int version;
|
||||
private RecipeType type;
|
||||
private String description;
|
||||
private YieldPercentage yieldPercentage;
|
||||
private Integer shelfLifeDays;
|
||||
private Quantity outputQuantity;
|
||||
private RecipeStatus status;
|
||||
private final LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
private Recipe(
|
||||
RecipeId id,
|
||||
RecipeName name,
|
||||
int version,
|
||||
RecipeType type,
|
||||
String description,
|
||||
YieldPercentage yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
Quantity outputQuantity,
|
||||
RecipeStatus status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.version = version;
|
||||
this.type = type;
|
||||
this.description = description;
|
||||
this.yieldPercentage = yieldPercentage;
|
||||
this.shelfLifeDays = shelfLifeDays;
|
||||
this.outputQuantity = outputQuantity;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Creates a new Recipe in DRAFT status from raw inputs.
|
||||
* Orchestrates VO validation internally.
|
||||
*/
|
||||
public static Result<RecipeError, Recipe> create(RecipeDraft draft) {
|
||||
// 1. Name (required)
|
||||
RecipeName name;
|
||||
switch (RecipeName.of(draft.name())) {
|
||||
case Failure(var msg) -> { return Result.failure(new RecipeError.ValidationFailure(msg)); }
|
||||
case Success(var val) -> name = val;
|
||||
}
|
||||
|
||||
// 2. Version >= 1
|
||||
if (draft.version() < 1) {
|
||||
return Result.failure(new RecipeError.ValidationFailure("Version must be >= 1, was: " + draft.version()));
|
||||
}
|
||||
|
||||
// 3. YieldPercentage (1-200)
|
||||
YieldPercentage yieldPercentage;
|
||||
switch (YieldPercentage.of(draft.yieldPercentage())) {
|
||||
case Failure(var err) -> { return Result.failure(new RecipeError.ValidationFailure(err.message())); }
|
||||
case Success(var val) -> yieldPercentage = val;
|
||||
}
|
||||
|
||||
// 4. ShelfLifeDays: required and > 0 for FINISHED_PRODUCT and INTERMEDIATE
|
||||
Integer shelfLifeDays = draft.shelfLifeDays();
|
||||
if (draft.type() == RecipeType.FINISHED_PRODUCT || draft.type() == RecipeType.INTERMEDIATE) {
|
||||
if (shelfLifeDays == null || shelfLifeDays <= 0) {
|
||||
return Result.failure(new RecipeError.InvalidShelfLife(
|
||||
"ShelfLifeDays must be > 0 for " + draft.type() + ", was: " + shelfLifeDays));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Output Quantity (required, positive)
|
||||
Quantity outputQuantity;
|
||||
try {
|
||||
BigDecimal amount = new BigDecimal(draft.outputQuantity());
|
||||
UnitOfMeasure uom = UnitOfMeasure.valueOf(draft.outputUom());
|
||||
switch (Quantity.of(amount, uom)) {
|
||||
case Failure(var err) -> { return Result.failure(new RecipeError.ValidationFailure(err.message())); }
|
||||
case Success(var val) -> outputQuantity = val;
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new RecipeError.ValidationFailure("Invalid output quantity: " + e.getMessage()));
|
||||
}
|
||||
|
||||
var now = LocalDateTime.now();
|
||||
return Result.success(new Recipe(
|
||||
RecipeId.generate(), name, draft.version(), draft.type(),
|
||||
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
|
||||
RecipeStatus.DRAFT, now, now
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitutes a Recipe from persistence. No validation.
|
||||
*/
|
||||
public static Recipe reconstitute(
|
||||
RecipeId id,
|
||||
RecipeName name,
|
||||
int version,
|
||||
RecipeType type,
|
||||
String description,
|
||||
YieldPercentage yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
Quantity outputQuantity,
|
||||
RecipeStatus status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
return new Recipe(id, name, version, type, description,
|
||||
yieldPercentage, shelfLifeDays, outputQuantity, status, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
public RecipeId id() { return id; }
|
||||
public RecipeName name() { return name; }
|
||||
public int version() { return version; }
|
||||
public RecipeType type() { return type; }
|
||||
public String description() { return description; }
|
||||
public YieldPercentage yieldPercentage() { return yieldPercentage; }
|
||||
public Integer shelfLifeDays() { return shelfLifeDays; }
|
||||
public Quantity outputQuantity() { return outputQuantity; }
|
||||
public RecipeStatus status() { return status; }
|
||||
public LocalDateTime createdAt() { return createdAt; }
|
||||
public LocalDateTime updatedAt() { return updatedAt; }
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private void touch() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof Recipe other)) return false;
|
||||
return id.equals(other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Recipe{id=" + id + ", name=" + name + ", version=" + version + ", status=" + status + "}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
/**
|
||||
* Draft for creating a new Recipe. Application layer builds this from raw command inputs.
|
||||
* The Recipe aggregate validates and constructs VOs internally.
|
||||
*
|
||||
* @param name Recipe name (required)
|
||||
* @param version Version number (required, >= 1)
|
||||
* @param type Recipe type (required)
|
||||
* @param description Free-text description (optional)
|
||||
* @param yieldPercentage Yield percentage 1-200 (required)
|
||||
* @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE)
|
||||
* @param outputQuantity Expected output quantity amount (required)
|
||||
* @param outputUom Expected output unit of measure (required)
|
||||
*/
|
||||
public record RecipeDraft(
|
||||
String name,
|
||||
int version,
|
||||
RecipeType type,
|
||||
String description,
|
||||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom
|
||||
) {}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
public sealed interface RecipeError {
|
||||
|
||||
String code();
|
||||
String message();
|
||||
|
||||
record RecipeNotFound(RecipeId id) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_NOT_FOUND"; }
|
||||
@Override public String message() { return "Recipe with ID '" + id.value() + "' not found"; }
|
||||
}
|
||||
|
||||
record NameAndVersionAlreadyExists(String name, int version) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_NAME_VERSION_EXISTS"; }
|
||||
@Override public String message() { return "Recipe '" + name + "' version " + version + " already exists"; }
|
||||
}
|
||||
|
||||
record InvalidShelfLife(String reason) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_INVALID_SHELF_LIFE"; }
|
||||
@Override public String message() { return "Invalid shelf life: " + reason; }
|
||||
}
|
||||
|
||||
record ValidationFailure(String message) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_VALIDATION_ERROR"; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements RecipeError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
||||
record RepositoryFailure(String message) implements RecipeError {
|
||||
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeId(String value) {
|
||||
|
||||
public RecipeId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("RecipeId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static RecipeId generate() {
|
||||
return new RecipeId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public static RecipeId of(String value) {
|
||||
return new RecipeId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
public record RecipeName(String value) {
|
||||
|
||||
public RecipeName {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("RecipeName must not be blank");
|
||||
}
|
||||
if (value.length() > 200) {
|
||||
throw new IllegalArgumentException("RecipeName must not exceed 200 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public static Result<String, RecipeName> of(String value) {
|
||||
try {
|
||||
return Result.success(new RecipeName(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RecipeRepository {
|
||||
|
||||
Result<RepositoryError, Optional<Recipe>> findById(RecipeId id);
|
||||
|
||||
Result<RepositoryError, List<Recipe>> findAll();
|
||||
|
||||
Result<RepositoryError, Void> save(Recipe recipe);
|
||||
|
||||
Result<RepositoryError, Void> delete(Recipe recipe);
|
||||
|
||||
Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
public enum RecipeStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
ARCHIVED
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
public enum RecipeType {
|
||||
RAW_MATERIAL,
|
||||
INTERMEDIATE,
|
||||
FINISHED_PRODUCT
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
import de.effigenix.domain.production.RecipeRepository;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class ProductionUseCaseConfiguration {
|
||||
|
||||
@Bean
|
||||
public CreateRecipe createRecipe(RecipeRepository recipeRepository) {
|
||||
return new CreateRecipe(recipeRepository);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package de.effigenix.infrastructure.production.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipes",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uq_recipe_name_version", columnNames = {"name", "version"}))
|
||||
public class RecipeEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(name = "version", nullable = false)
|
||||
private int version;
|
||||
|
||||
@Column(name = "type", nullable = false, length = 30)
|
||||
private String type;
|
||||
|
||||
@Column(name = "description", length = 2000)
|
||||
private String description;
|
||||
|
||||
@Column(name = "yield_percentage", nullable = false)
|
||||
private int yieldPercentage;
|
||||
|
||||
@Column(name = "shelf_life_days")
|
||||
private Integer shelfLifeDays;
|
||||
|
||||
@Column(name = "output_quantity", nullable = false, precision = 19, scale = 6)
|
||||
private BigDecimal outputQuantity;
|
||||
|
||||
@Column(name = "output_uom", nullable = false, length = 20)
|
||||
private String outputUom;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
protected RecipeEntity() {}
|
||||
|
||||
public RecipeEntity(String id, String name, int version, String type, String description,
|
||||
int yieldPercentage, Integer shelfLifeDays, BigDecimal outputQuantity,
|
||||
String outputUom, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.version = version;
|
||||
this.type = type;
|
||||
this.description = description;
|
||||
this.yieldPercentage = yieldPercentage;
|
||||
this.shelfLifeDays = shelfLifeDays;
|
||||
this.outputQuantity = outputQuantity;
|
||||
this.outputUom = outputUom;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public int getVersion() { return version; }
|
||||
public String getType() { return type; }
|
||||
public String getDescription() { return description; }
|
||||
public int getYieldPercentage() { return yieldPercentage; }
|
||||
public Integer getShelfLifeDays() { return shelfLifeDays; }
|
||||
public BigDecimal getOutputQuantity() { return outputQuantity; }
|
||||
public String getOutputUom() { return outputUom; }
|
||||
public String getStatus() { return status; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
||||
public void setId(String id) { this.id = id; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public void setVersion(int version) { this.version = version; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public void setYieldPercentage(int yieldPercentage) { this.yieldPercentage = yieldPercentage; }
|
||||
public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; }
|
||||
public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; }
|
||||
public void setOutputUom(String outputUom) { this.outputUom = outputUom; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package de.effigenix.infrastructure.production.persistence.mapper;
|
||||
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class RecipeMapper {
|
||||
|
||||
public RecipeEntity toEntity(Recipe recipe) {
|
||||
return new RecipeEntity(
|
||||
recipe.id().value(),
|
||||
recipe.name().value(),
|
||||
recipe.version(),
|
||||
recipe.type().name(),
|
||||
recipe.description(),
|
||||
recipe.yieldPercentage().value(),
|
||||
recipe.shelfLifeDays(),
|
||||
recipe.outputQuantity().amount(),
|
||||
recipe.outputQuantity().uom().name(),
|
||||
recipe.status().name(),
|
||||
recipe.createdAt(),
|
||||
recipe.updatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public Recipe toDomain(RecipeEntity entity) {
|
||||
return Recipe.reconstitute(
|
||||
RecipeId.of(entity.getId()),
|
||||
new RecipeName(entity.getName()),
|
||||
entity.getVersion(),
|
||||
RecipeType.valueOf(entity.getType()),
|
||||
entity.getDescription(),
|
||||
new YieldPercentage(entity.getYieldPercentage()),
|
||||
entity.getShelfLifeDays(),
|
||||
Quantity.reconstitute(
|
||||
entity.getOutputQuantity(),
|
||||
UnitOfMeasure.valueOf(entity.getOutputUom()),
|
||||
null, null
|
||||
),
|
||||
RecipeStatus.valueOf(entity.getStatus()),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package de.effigenix.infrastructure.production.persistence.repository;
|
||||
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.infrastructure.production.persistence.mapper.RecipeMapper;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
@Profile("!no-db")
|
||||
@Transactional(readOnly = true)
|
||||
public class JpaRecipeRepository implements RecipeRepository {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JpaRecipeRepository.class);
|
||||
|
||||
private final RecipeJpaRepository jpaRepository;
|
||||
private final RecipeMapper mapper;
|
||||
|
||||
public JpaRecipeRepository(RecipeJpaRepository jpaRepository, RecipeMapper mapper) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<Recipe>> findById(RecipeId id) {
|
||||
try {
|
||||
Optional<Recipe> result = jpaRepository.findById(id.value())
|
||||
.map(mapper::toDomain);
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
logger.trace("Database error in findById", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<Recipe>> findAll() {
|
||||
try {
|
||||
List<Recipe> result = jpaRepository.findAll().stream()
|
||||
.map(mapper::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
logger.trace("Database error in findAll", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> save(Recipe recipe) {
|
||||
try {
|
||||
jpaRepository.save(mapper.toEntity(recipe));
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
logger.trace("Database error in save", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> delete(Recipe recipe) {
|
||||
try {
|
||||
jpaRepository.deleteById(recipe.id().value());
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
logger.trace("Database error in delete", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
|
||||
try {
|
||||
return Result.success(jpaRepository.existsByNameAndVersion(name, version));
|
||||
} catch (Exception e) {
|
||||
logger.trace("Database error in existsByNameAndVersion", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package de.effigenix.infrastructure.production.persistence.repository;
|
||||
|
||||
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface RecipeJpaRepository extends JpaRepository<RecipeEntity, String> {
|
||||
|
||||
List<RecipeEntity> findByStatus(String status);
|
||||
|
||||
boolean existsByNameAndVersion(String name, int version);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package de.effigenix.infrastructure.production.web.controller;
|
||||
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||
import de.effigenix.domain.production.RecipeError;
|
||||
import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest;
|
||||
import de.effigenix.infrastructure.production.web.dto.RecipeResponse;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/recipes")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
@Tag(name = "Recipes", description = "Recipe management endpoints")
|
||||
public class RecipeController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RecipeController.class);
|
||||
|
||||
private final CreateRecipe createRecipe;
|
||||
|
||||
public RecipeController(CreateRecipe createRecipe) {
|
||||
this.createRecipe = createRecipe;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
|
||||
public ResponseEntity<RecipeResponse> createRecipe(
|
||||
@Valid @RequestBody CreateRecipeRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
var actorId = extractActorId(authentication);
|
||||
logger.info("Creating recipe: {} v{} by actor: {}", request.name(), request.version(), actorId.value());
|
||||
|
||||
var cmd = new CreateRecipeCommand(
|
||||
request.name(), request.version(), request.type(), request.description(),
|
||||
request.yieldPercentage(), request.shelfLifeDays(),
|
||||
request.outputQuantity(), request.outputUom()
|
||||
);
|
||||
var result = createRecipe.execute(cmd, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new RecipeDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Recipe created: {} v{}", request.name(), request.version());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
private ActorId extractActorId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new IllegalStateException("No authentication found in SecurityContext");
|
||||
}
|
||||
return ActorId.of(authentication.getName());
|
||||
}
|
||||
|
||||
public static class RecipeDomainErrorException extends RuntimeException {
|
||||
private final RecipeError error;
|
||||
|
||||
public RecipeDomainErrorException(RecipeError error) {
|
||||
super(error.message());
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public RecipeError getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import de.effigenix.domain.production.RecipeType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CreateRecipeRequest(
|
||||
@NotBlank String name,
|
||||
int version,
|
||||
@NotNull RecipeType type,
|
||||
String description,
|
||||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
@NotBlank String outputQuantity,
|
||||
@NotBlank String outputUom
|
||||
) {}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import de.effigenix.domain.production.Recipe;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record RecipeResponse(
|
||||
String id,
|
||||
String name,
|
||||
int version,
|
||||
String type,
|
||||
String description,
|
||||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom,
|
||||
String status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
public static RecipeResponse from(Recipe recipe) {
|
||||
return new RecipeResponse(
|
||||
recipe.id().value(),
|
||||
recipe.name().value(),
|
||||
recipe.version(),
|
||||
recipe.type().name(),
|
||||
recipe.description(),
|
||||
recipe.yieldPercentage().value(),
|
||||
recipe.shelfLifeDays(),
|
||||
recipe.outputQuantity().amount().toPlainString(),
|
||||
recipe.outputQuantity().uom().name(),
|
||||
recipe.status().name(),
|
||||
recipe.createdAt(),
|
||||
recipe.updatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package de.effigenix.infrastructure.production.web.exception;
|
||||
|
||||
import de.effigenix.domain.production.RecipeError;
|
||||
|
||||
public final class ProductionErrorHttpStatusMapper {
|
||||
|
||||
private ProductionErrorHttpStatusMapper() {}
|
||||
|
||||
public static int toHttpStatus(RecipeError error) {
|
||||
return switch (error) {
|
||||
case RecipeError.RecipeNotFound e -> 404;
|
||||
case RecipeError.NameAndVersionAlreadyExists e -> 409;
|
||||
case RecipeError.InvalidShelfLife e -> 400;
|
||||
case RecipeError.ValidationFailure e -> 400;
|
||||
case RecipeError.Unauthorized e -> 403;
|
||||
case RecipeError.RepositoryFailure e -> 500;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import de.effigenix.domain.masterdata.ArticleError;
|
|||
import de.effigenix.domain.masterdata.ProductCategoryError;
|
||||
import de.effigenix.domain.masterdata.CustomerError;
|
||||
import de.effigenix.domain.masterdata.SupplierError;
|
||||
import de.effigenix.domain.production.RecipeError;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
|
||||
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
|
||||
|
|
@ -12,6 +13,8 @@ import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
|
|||
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
|
||||
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
|
||||
import de.effigenix.infrastructure.masterdata.web.controller.SupplierController;
|
||||
import de.effigenix.infrastructure.production.web.controller.RecipeController;
|
||||
import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpStatusMapper;
|
||||
import de.effigenix.infrastructure.usermanagement.web.controller.AuthController;
|
||||
|
|
@ -201,6 +204,25 @@ public class GlobalExceptionHandler {
|
|||
return ResponseEntity.status(status).body(errorResponse);
|
||||
}
|
||||
|
||||
@ExceptionHandler(RecipeController.RecipeDomainErrorException.class)
|
||||
public ResponseEntity<ErrorResponse> handleRecipeDomainError(
|
||||
RecipeController.RecipeDomainErrorException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
RecipeError error = ex.getError();
|
||||
int status = ProductionErrorHttpStatusMapper.toHttpStatus(error);
|
||||
logDomainError("Recipe", error.code(), error.message(), status);
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
error.code(),
|
||||
error.message(),
|
||||
status,
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(status).body(errorResponse);
|
||||
}
|
||||
|
||||
@ExceptionHandler(RoleController.RoleDomainErrorException.class)
|
||||
public ResponseEntity<ErrorResponse> handleRoleDomainError(
|
||||
RoleController.RoleDomainErrorException ex,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<?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="010-create-recipes-table" author="effigenix">
|
||||
<createTable tableName="recipes">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="type" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="description" type="VARCHAR(2000)"/>
|
||||
<column name="yield_percentage" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="shelf_life_days" type="INT"/>
|
||||
<column name="output_quantity" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="output_uom" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="DRAFT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="updated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addUniqueConstraint tableName="recipes" columnNames="name, version"
|
||||
constraintName="uq_recipe_name_version"/>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE recipes ADD CONSTRAINT chk_recipe_type CHECK (type IN ('RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT'));
|
||||
ALTER TABLE recipes ADD CONSTRAINT chk_recipe_status CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED'));
|
||||
ALTER TABLE recipes ADD CONSTRAINT chk_recipe_yield CHECK (yield_percentage BETWEEN 1 AND 200);
|
||||
</sql>
|
||||
|
||||
<createIndex tableName="recipes" indexName="idx_recipes_name_version">
|
||||
<column name="name"/>
|
||||
<column name="version"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="recipes" indexName="idx_recipes_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -14,5 +14,6 @@
|
|||
<include file="db/changelog/changes/007-create-customer-schema.xml"/>
|
||||
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/>
|
||||
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
|
||||
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
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("Recipe Aggregate")
|
||||
class RecipeTest {
|
||||
|
||||
private RecipeDraft validDraft() {
|
||||
return new RecipeDraft(
|
||||
"Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT,
|
||||
"Grobe Bratwurst nach Hausrezept", 85,
|
||||
14, "100", "KILOGRAM"
|
||||
);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("create()")
|
||||
class Create {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create recipe in DRAFT status with valid inputs")
|
||||
void should_CreateInDraftStatus_When_ValidInputs() {
|
||||
var result = Recipe.create(validDraft());
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var recipe = result.unsafeGetValue();
|
||||
assertThat(recipe.id()).isNotNull();
|
||||
assertThat(recipe.name().value()).isEqualTo("Bratwurst Grob");
|
||||
assertThat(recipe.version()).isEqualTo(1);
|
||||
assertThat(recipe.type()).isEqualTo(RecipeType.FINISHED_PRODUCT);
|
||||
assertThat(recipe.description()).isEqualTo("Grobe Bratwurst nach Hausrezept");
|
||||
assertThat(recipe.yieldPercentage().value()).isEqualTo(85);
|
||||
assertThat(recipe.shelfLifeDays()).isEqualTo(14);
|
||||
assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100");
|
||||
assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
|
||||
assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT);
|
||||
assertThat(recipe.createdAt()).isNotNull();
|
||||
assertThat(recipe.updatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when name is blank")
|
||||
void should_Fail_When_NameIsBlank() {
|
||||
var draft = new RecipeDraft(
|
||||
"", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when name is null")
|
||||
void should_Fail_When_NameIsNull() {
|
||||
var draft = new RecipeDraft(
|
||||
null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when version is less than 1")
|
||||
void should_Fail_When_VersionLessThanOne() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when yield percentage is 0")
|
||||
void should_Fail_When_YieldPercentageIsZero() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when yield percentage is 201")
|
||||
void should_Fail_When_YieldPercentageIs201() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT")
|
||||
void should_Fail_When_ShelfLifeZeroForFinishedProduct() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when shelf life is null for FINISHED_PRODUCT")
|
||||
void should_Fail_When_ShelfLifeNullForFinishedProduct() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when shelf life is 0 for INTERMEDIATE")
|
||||
void should_Fail_When_ShelfLifeZeroForIntermediate() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should allow null shelf life for RAW_MATERIAL")
|
||||
void should_AllowNullShelfLife_When_RawMaterial() {
|
||||
var draft = new RecipeDraft(
|
||||
"Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().shelfLifeDays()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when output quantity is zero")
|
||||
void should_Fail_When_OutputQuantityZero() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when output UoM is invalid")
|
||||
void should_Fail_When_OutputUomInvalid() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should allow description to be null")
|
||||
void should_AllowNullDescription() {
|
||||
var draft = new RecipeDraft(
|
||||
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
|
||||
);
|
||||
|
||||
var result = Recipe.create(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().description()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Equality")
|
||||
class Equality {
|
||||
|
||||
@Test
|
||||
@DisplayName("should be equal when same ID")
|
||||
void should_BeEqual_When_SameId() {
|
||||
var recipe1 = Recipe.create(validDraft()).unsafeGetValue();
|
||||
var recipe2 = Recipe.reconstitute(
|
||||
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()
|
||||
);
|
||||
|
||||
assertThat(recipe1).isEqualTo(recipe2);
|
||||
assertThat(recipe1.hashCode()).isEqualTo(recipe2.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should not be equal when different ID")
|
||||
void should_NotBeEqual_When_DifferentId() {
|
||||
var recipe1 = Recipe.create(validDraft()).unsafeGetValue();
|
||||
var recipe2 = Recipe.create(validDraft()).unsafeGetValue();
|
||||
|
||||
assertThat(recipe1).isNotEqualTo(recipe2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue