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

feat(production): Rezept aktivieren (#29)

Rezepte können vom DRAFT- in den ACTIVE-Status überführt werden.
Voraussetzung: mindestens eine Zutat muss vorhanden sein.
Inkl. Use Case, REST-Endpoint POST /recipes/{id}/activate,
Domain-Tests und Error Handling.
This commit is contained in:
Sebastian Frick 2026-02-19 21:24:39 +01:00
parent cf93b847e5
commit a132211a74
11 changed files with 229 additions and 3 deletions

View file

@ -0,0 +1,53 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.ActivateRecipeCommand;
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 ActivateRecipe {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public ActivateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(ActivateRecipeCommand 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.activate()) {
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

@ -0,0 +1,4 @@
package de.effigenix.application.production.command;
public record ActivateRecipeCommand(String recipeId) {
}

View file

@ -25,6 +25,8 @@ import static de.effigenix.shared.common.Result.*;
* 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
* 11. Recipe can only be activated when it has at least one ingredient
* 12. Recipe can only be activated from DRAFT status
*/
public class Recipe {
@ -214,6 +216,20 @@ public class Recipe {
return Result.success(null);
}
// ==================== Status Transitions ====================
public Result<RecipeError, Void> activate() {
if (status != RecipeStatus.DRAFT) {
return Result.failure(new RecipeError.InvalidStatusTransition(status, RecipeStatus.ACTIVE));
}
if (ingredients.isEmpty()) {
return Result.failure(new RecipeError.NoIngredients());
}
this.status = RecipeStatus.ACTIVE;
touch();
return Result.success(null);
}
// ==================== Getters ====================
public RecipeId id() { return id; }

View file

@ -49,6 +49,16 @@ public sealed interface RecipeError {
@Override public String message() { return "Production step with number " + stepNumber + " not found"; }
}
record NoIngredients() implements RecipeError {
@Override public String code() { return "RECIPE_NO_INGREDIENTS"; }
@Override public String message() { return "Recipe must have at least one ingredient to be activated"; }
}
record InvalidStatusTransition(RecipeStatus current, RecipeStatus target) implements RecipeError {
@Override public String code() { return "RECIPE_INVALID_STATUS_TRANSITION"; }
@Override public String message() { return "Cannot transition from " + current + " to " + target; }
}
record Unauthorized(String message) implements RecipeError {
@Override public String code() { return "UNAUTHORIZED"; }
}

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.production.ActivateRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
@ -37,4 +38,9 @@ public class ProductionUseCaseConfiguration {
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new RemoveProductionStep(recipeRepository, authorizationPort);
}
@Bean
public ActivateRecipe activateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new ActivateRecipe(recipeRepository, authorizationPort);
}
}

View file

@ -1,10 +1,12 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.ActivateRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.application.production.command.ActivateRecipeCommand;
import de.effigenix.application.production.command.AddProductionStepCommand;
import de.effigenix.application.production.command.AddRecipeIngredientCommand;
import de.effigenix.application.production.command.CreateRecipeCommand;
@ -40,17 +42,20 @@ public class RecipeController {
private final RemoveRecipeIngredient removeRecipeIngredient;
private final AddProductionStep addProductionStep;
private final RemoveProductionStep removeProductionStep;
private final ActivateRecipe activateRecipe;
public RecipeController(CreateRecipe createRecipe,
AddRecipeIngredient addRecipeIngredient,
RemoveRecipeIngredient removeRecipeIngredient,
AddProductionStep addProductionStep,
RemoveProductionStep removeProductionStep) {
RemoveProductionStep removeProductionStep,
ActivateRecipe activateRecipe) {
this.createRecipe = createRecipe;
this.addRecipeIngredient = addRecipeIngredient;
this.removeRecipeIngredient = removeRecipeIngredient;
this.addProductionStep = addProductionStep;
this.removeProductionStep = removeProductionStep;
this.activateRecipe = activateRecipe;
}
@PostMapping
@ -167,6 +172,26 @@ public class RecipeController {
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/activate")
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<RecipeResponse> activateRecipe(
@PathVariable("id") String recipeId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Activating recipe: {} by actor: {}", recipeId, actorId.value());
var cmd = new ActivateRecipeCommand(recipeId);
var result = activateRecipe.execute(cmd, actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
logger.info("Recipe activated: {}", recipeId);
return ResponseEntity.ok(RecipeResponse.from(result.unsafeGetValue()));
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");

View file

@ -17,6 +17,8 @@ public final class ProductionErrorHttpStatusMapper {
case RecipeError.InvalidShelfLife e -> 400;
case RecipeError.ValidationFailure e -> 400;
case RecipeError.NotInDraftStatus e -> 409;
case RecipeError.InvalidStatusTransition e -> 409;
case RecipeError.NoIngredients e -> 400;
case RecipeError.Unauthorized e -> 403;
case RecipeError.RepositoryFailure e -> 500;
};

View file

@ -468,6 +468,78 @@ class RecipeTest {
}
}
@Nested
@DisplayName("activate()")
class Activate {
@Test
@DisplayName("should activate DRAFT recipe with ingredients")
void should_Activate_When_DraftWithIngredients() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
recipe.addIngredient(validIngredientDraft(1));
var updatedBefore = recipe.updatedAt();
var result = recipe.activate();
assertThat(result.isSuccess()).isTrue();
assertThat(recipe.status()).isEqualTo(RecipeStatus.ACTIVE);
assertThat(recipe.updatedAt()).isAfter(updatedBefore);
}
@Test
@DisplayName("should fail when DRAFT recipe has no ingredients")
void should_Fail_When_NoIngredients() {
var recipe = Recipe.create(validDraft()).unsafeGetValue();
var result = recipe.activate();
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NoIngredients.class);
}
@Test
@DisplayName("should fail when recipe is already ACTIVE")
void should_Fail_When_AlreadyActive() {
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.activate();
assertThat(result.isFailure()).isTrue();
var error = result.unsafeGetError();
assertThat(error).isInstanceOf(RecipeError.InvalidStatusTransition.class);
var transition = (RecipeError.InvalidStatusTransition) error;
assertThat(transition.current()).isEqualTo(RecipeStatus.ACTIVE);
assertThat(transition.target()).isEqualTo(RecipeStatus.ACTIVE);
}
@Test
@DisplayName("should fail when recipe is ARCHIVED")
void should_Fail_When_Archived() {
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.ARCHIVED, List.of(), List.of(),
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
);
var result = recipe.activate();
assertThat(result.isFailure()).isTrue();
var error = result.unsafeGetError();
assertThat(error).isInstanceOf(RecipeError.InvalidStatusTransition.class);
var transition = (RecipeError.InvalidStatusTransition) error;
assertThat(transition.current()).isEqualTo(RecipeStatus.ARCHIVED);
assertThat(transition.target()).isEqualTo(RecipeStatus.ACTIVE);
}
}
@Nested
@DisplayName("Equality")
class Equality {

File diff suppressed because one or more lines are too long

View file

@ -388,6 +388,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/recipes/{id}/activate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["activateRecipe"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/inventory/storage-locations": {
parameters: {
query?: never;
@ -2180,6 +2196,28 @@ export interface operations {
};
};
};
activateRecipe: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RecipeResponse"];
};
};
};
};
listStorageLocations: {
parameters: {
query?: {

View file

@ -34,7 +34,7 @@ if curl -sf "$API_DOCS_URL" -o /dev/null 2>/dev/null; then
echo "✓ Backend läuft bereits"
else
echo "→ Backend wird gestartet..."
mvn -f "$BACKEND_DIR/pom.xml" spring-boot:run -q &
mvn -f "$BACKEND_DIR/pom.xml" spring-boot:run -Pno-db -q &
BACKEND_PID=$!
wait_for_backend
fi