mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 14:09:34 +01:00
feat(production): Rezepte abfragen per ID und nach Status filtern (#31)
GET /api/recipes/{id} liefert vollständiges Rezept inkl. Zutaten und Schritte.
GET /api/recipes?status=ACTIVE liefert Summary-Liste mit ingredientCount/stepCount.
This commit is contained in:
parent
6feb3a9f1c
commit
f2003a3093
12 changed files with 661 additions and 0 deletions
|
|
@ -0,0 +1,36 @@
|
||||||
|
package de.effigenix.application.production;
|
||||||
|
|
||||||
|
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(readOnly = true)
|
||||||
|
public class GetRecipe {
|
||||||
|
|
||||||
|
private final RecipeRepository recipeRepository;
|
||||||
|
private final AuthorizationPort authorizationPort;
|
||||||
|
|
||||||
|
public GetRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.authorizationPort = authorizationPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<RecipeError, Recipe> execute(RecipeId recipeId, ActorId performedBy) {
|
||||||
|
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_READ)) {
|
||||||
|
return Result.failure(new RecipeError.Unauthorized("Not authorized to read recipes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
return Result.success(opt.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package de.effigenix.application.production;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class ListRecipes {
|
||||||
|
|
||||||
|
private final RecipeRepository recipeRepository;
|
||||||
|
private final AuthorizationPort authorizationPort;
|
||||||
|
|
||||||
|
public ListRecipes(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.authorizationPort = authorizationPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<RecipeError, List<Recipe>> execute(ActorId performedBy) {
|
||||||
|
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_READ)) {
|
||||||
|
return Result.failure(new RecipeError.Unauthorized("Not authorized to read recipes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (recipeRepository.findAll()) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var recipes) ->
|
||||||
|
{ return Result.success(recipes); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<RecipeError, List<Recipe>> executeByStatus(RecipeStatus status, ActorId performedBy) {
|
||||||
|
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_READ)) {
|
||||||
|
return Result.failure(new RecipeError.Unauthorized("Not authorized to read recipes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (recipeRepository.findByStatus(status)) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var recipes) ->
|
||||||
|
{ return Result.success(recipes); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,4 +17,6 @@ public interface RecipeRepository {
|
||||||
Result<RepositoryError, Void> delete(Recipe recipe);
|
Result<RepositoryError, Void> delete(Recipe recipe);
|
||||||
|
|
||||||
Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version);
|
Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import de.effigenix.application.production.ArchiveRecipe;
|
||||||
import de.effigenix.application.production.AddProductionStep;
|
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.GetRecipe;
|
||||||
|
import de.effigenix.application.production.ListRecipes;
|
||||||
import de.effigenix.application.production.RemoveProductionStep;
|
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;
|
||||||
|
|
@ -40,6 +42,16 @@ public class ProductionUseCaseConfiguration {
|
||||||
return new RemoveProductionStep(recipeRepository, authorizationPort);
|
return new RemoveProductionStep(recipeRepository, authorizationPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GetRecipe getRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
|
return new GetRecipe(recipeRepository, authorizationPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ListRecipes listRecipes(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
|
return new ListRecipes(recipeRepository, authorizationPort);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public ActivateRecipe activateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
public ActivateRecipe activateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
return new ActivateRecipe(recipeRepository, authorizationPort);
|
return new ActivateRecipe(recipeRepository, authorizationPort);
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,19 @@ public class JpaRecipeRepository implements RecipeRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status) {
|
||||||
|
try {
|
||||||
|
List<Recipe> result = jpaRepository.findByStatus(status.name()).stream()
|
||||||
|
.map(mapper::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.trace("Database error in findByStatus", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
|
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import de.effigenix.application.production.ArchiveRecipe;
|
||||||
import de.effigenix.application.production.AddProductionStep;
|
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.GetRecipe;
|
||||||
|
import de.effigenix.application.production.ListRecipes;
|
||||||
import de.effigenix.application.production.RemoveProductionStep;
|
import de.effigenix.application.production.RemoveProductionStep;
|
||||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
import de.effigenix.application.production.RemoveRecipeIngredient;
|
||||||
import de.effigenix.application.production.command.ActivateRecipeCommand;
|
import de.effigenix.application.production.command.ActivateRecipeCommand;
|
||||||
|
|
@ -15,10 +17,13 @@ import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||||
import de.effigenix.application.production.command.RemoveProductionStepCommand;
|
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.domain.production.RecipeId;
|
||||||
|
import de.effigenix.domain.production.RecipeStatus;
|
||||||
import de.effigenix.infrastructure.production.web.dto.AddProductionStepRequest;
|
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;
|
||||||
|
import de.effigenix.infrastructure.production.web.dto.RecipeSummaryResponse;
|
||||||
import de.effigenix.shared.security.ActorId;
|
import de.effigenix.shared.security.ActorId;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
@ -31,6 +36,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/recipes")
|
@RequestMapping("/api/recipes")
|
||||||
@SecurityRequirement(name = "Bearer Authentication")
|
@SecurityRequirement(name = "Bearer Authentication")
|
||||||
|
|
@ -40,6 +47,8 @@ public class RecipeController {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(RecipeController.class);
|
private static final Logger logger = LoggerFactory.getLogger(RecipeController.class);
|
||||||
|
|
||||||
private final CreateRecipe createRecipe;
|
private final CreateRecipe createRecipe;
|
||||||
|
private final GetRecipe getRecipe;
|
||||||
|
private final ListRecipes listRecipes;
|
||||||
private final AddRecipeIngredient addRecipeIngredient;
|
private final AddRecipeIngredient addRecipeIngredient;
|
||||||
private final RemoveRecipeIngredient removeRecipeIngredient;
|
private final RemoveRecipeIngredient removeRecipeIngredient;
|
||||||
private final AddProductionStep addProductionStep;
|
private final AddProductionStep addProductionStep;
|
||||||
|
|
@ -48,6 +57,8 @@ public class RecipeController {
|
||||||
private final ArchiveRecipe archiveRecipe;
|
private final ArchiveRecipe archiveRecipe;
|
||||||
|
|
||||||
public RecipeController(CreateRecipe createRecipe,
|
public RecipeController(CreateRecipe createRecipe,
|
||||||
|
GetRecipe getRecipe,
|
||||||
|
ListRecipes listRecipes,
|
||||||
AddRecipeIngredient addRecipeIngredient,
|
AddRecipeIngredient addRecipeIngredient,
|
||||||
RemoveRecipeIngredient removeRecipeIngredient,
|
RemoveRecipeIngredient removeRecipeIngredient,
|
||||||
AddProductionStep addProductionStep,
|
AddProductionStep addProductionStep,
|
||||||
|
|
@ -55,6 +66,8 @@ public class RecipeController {
|
||||||
ActivateRecipe activateRecipe,
|
ActivateRecipe activateRecipe,
|
||||||
ArchiveRecipe archiveRecipe) {
|
ArchiveRecipe archiveRecipe) {
|
||||||
this.createRecipe = createRecipe;
|
this.createRecipe = createRecipe;
|
||||||
|
this.getRecipe = getRecipe;
|
||||||
|
this.listRecipes = listRecipes;
|
||||||
this.addRecipeIngredient = addRecipeIngredient;
|
this.addRecipeIngredient = addRecipeIngredient;
|
||||||
this.removeRecipeIngredient = removeRecipeIngredient;
|
this.removeRecipeIngredient = removeRecipeIngredient;
|
||||||
this.addProductionStep = addProductionStep;
|
this.addProductionStep = addProductionStep;
|
||||||
|
|
@ -63,6 +76,54 @@ public class RecipeController {
|
||||||
this.archiveRecipe = archiveRecipe;
|
this.archiveRecipe = archiveRecipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('RECIPE_READ')")
|
||||||
|
public ResponseEntity<RecipeResponse> getRecipe(
|
||||||
|
@PathVariable("id") String recipeId,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
var actorId = extractActorId(authentication);
|
||||||
|
var result = getRecipe.execute(RecipeId.of(recipeId), actorId);
|
||||||
|
|
||||||
|
if (result.isFailure()) {
|
||||||
|
throw new RecipeDomainErrorException(result.unsafeGetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(RecipeResponse.from(result.unsafeGetValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('RECIPE_READ')")
|
||||||
|
public ResponseEntity<List<RecipeSummaryResponse>> listRecipes(
|
||||||
|
@RequestParam(value = "status", required = false) String status,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
var actorId = extractActorId(authentication);
|
||||||
|
|
||||||
|
RecipeStatus parsedStatus = null;
|
||||||
|
if (status != null) {
|
||||||
|
try {
|
||||||
|
parsedStatus = RecipeStatus.valueOf(status);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new RecipeDomainErrorException(
|
||||||
|
new RecipeError.ValidationFailure("Invalid status: " + status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = (parsedStatus != null)
|
||||||
|
? listRecipes.executeByStatus(parsedStatus, actorId)
|
||||||
|
: listRecipes.execute(actorId);
|
||||||
|
|
||||||
|
if (result.isFailure()) {
|
||||||
|
throw new RecipeDomainErrorException(result.unsafeGetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
var summaries = result.unsafeGetValue().stream()
|
||||||
|
.map(RecipeSummaryResponse::from)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(summaries);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
|
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
|
||||||
public ResponseEntity<RecipeResponse> createRecipe(
|
public ResponseEntity<RecipeResponse> createRecipe(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package de.effigenix.infrastructure.production.web.dto;
|
||||||
|
|
||||||
|
import de.effigenix.domain.production.Recipe;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredientCount", "stepCount", "createdAt", "updatedAt"})
|
||||||
|
public record RecipeSummaryResponse(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
int version,
|
||||||
|
String type,
|
||||||
|
String description,
|
||||||
|
int yieldPercentage,
|
||||||
|
@Schema(nullable = true) Integer shelfLifeDays,
|
||||||
|
String outputQuantity,
|
||||||
|
String outputUom,
|
||||||
|
String status,
|
||||||
|
int ingredientCount,
|
||||||
|
int stepCount,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
public static RecipeSummaryResponse from(Recipe recipe) {
|
||||||
|
return new RecipeSummaryResponse(
|
||||||
|
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.ingredients().size(),
|
||||||
|
recipe.productionSteps().size(),
|
||||||
|
recipe.createdAt(),
|
||||||
|
recipe.updatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package de.effigenix.infrastructure.stub;
|
||||||
import de.effigenix.domain.production.Recipe;
|
import de.effigenix.domain.production.Recipe;
|
||||||
import de.effigenix.domain.production.RecipeId;
|
import de.effigenix.domain.production.RecipeId;
|
||||||
import de.effigenix.domain.production.RecipeRepository;
|
import de.effigenix.domain.production.RecipeRepository;
|
||||||
|
import de.effigenix.domain.production.RecipeStatus;
|
||||||
import de.effigenix.shared.common.RepositoryError;
|
import de.effigenix.shared.common.RepositoryError;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
@ -42,4 +43,9 @@ public class StubRecipeRepository implements RecipeRepository {
|
||||||
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
|
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
|
||||||
return Result.failure(STUB_ERROR);
|
return Result.failure(STUB_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status) {
|
||||||
|
return Result.failure(STUB_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package de.effigenix.application.production;
|
||||||
|
|
||||||
|
import de.effigenix.domain.production.*;
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
|
import de.effigenix.shared.security.ActorId;
|
||||||
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("GetRecipe Use Case")
|
||||||
|
class GetRecipeTest {
|
||||||
|
|
||||||
|
@Mock private RecipeRepository recipeRepository;
|
||||||
|
@Mock private AuthorizationPort authPort;
|
||||||
|
|
||||||
|
private GetRecipe getRecipe;
|
||||||
|
private ActorId performedBy;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
getRecipe = new GetRecipe(recipeRepository, authPort);
|
||||||
|
performedBy = ActorId.of("admin-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Recipe activeRecipe(String id) {
|
||||||
|
return Recipe.reconstitute(
|
||||||
|
RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
|
||||||
|
"Beschreibung", new YieldPercentage(85), 14,
|
||||||
|
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||||
|
RecipeStatus.ACTIVE, List.of(), List.of(),
|
||||||
|
LocalDateTime.now(), LocalDateTime.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_ReturnRecipe_When_RecipeExists")
|
||||||
|
void should_ReturnRecipe_When_RecipeExists() {
|
||||||
|
var recipeId = RecipeId.of("recipe-1");
|
||||||
|
var recipe = activeRecipe("recipe-1");
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(true);
|
||||||
|
when(recipeRepository.findById(recipeId)).thenReturn(Result.success(Optional.of(recipe)));
|
||||||
|
|
||||||
|
var result = getRecipe.execute(recipeId, performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue().id()).isEqualTo(recipeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_FailWithRecipeNotFound_When_RecipeDoesNotExist")
|
||||||
|
void should_FailWithRecipeNotFound_When_RecipeDoesNotExist() {
|
||||||
|
var recipeId = RecipeId.of("nonexistent");
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(true);
|
||||||
|
when(recipeRepository.findById(recipeId)).thenReturn(Result.success(Optional.empty()));
|
||||||
|
|
||||||
|
var result = getRecipe.execute(recipeId, performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.RecipeNotFound.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||||
|
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(false);
|
||||||
|
|
||||||
|
var result = getRecipe.execute(RecipeId.of("recipe-1"), performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.Unauthorized.class);
|
||||||
|
verify(recipeRepository, never()).findById(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package de.effigenix.application.production;
|
||||||
|
|
||||||
|
import de.effigenix.domain.production.*;
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
|
import de.effigenix.shared.security.ActorId;
|
||||||
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("ListRecipes Use Case")
|
||||||
|
class ListRecipesTest {
|
||||||
|
|
||||||
|
@Mock private RecipeRepository recipeRepository;
|
||||||
|
@Mock private AuthorizationPort authPort;
|
||||||
|
|
||||||
|
private ListRecipes listRecipes;
|
||||||
|
private ActorId performedBy;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
listRecipes = new ListRecipes(recipeRepository, authPort);
|
||||||
|
performedBy = ActorId.of("admin-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Recipe recipeWithStatus(String id, RecipeStatus status) {
|
||||||
|
return Recipe.reconstitute(
|
||||||
|
RecipeId.of(id), new RecipeName("Rezept-" + id), 1, RecipeType.FINISHED_PRODUCT,
|
||||||
|
null, new YieldPercentage(85), 14,
|
||||||
|
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||||
|
status, List.of(), List.of(),
|
||||||
|
LocalDateTime.now(), LocalDateTime.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_ReturnAllRecipes_When_Authorized")
|
||||||
|
void should_ReturnAllRecipes_When_Authorized() {
|
||||||
|
var recipes = List.of(
|
||||||
|
recipeWithStatus("r1", RecipeStatus.DRAFT),
|
||||||
|
recipeWithStatus("r2", RecipeStatus.ACTIVE)
|
||||||
|
);
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(true);
|
||||||
|
when(recipeRepository.findAll()).thenReturn(Result.success(recipes));
|
||||||
|
|
||||||
|
var result = listRecipes.execute(performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_ReturnFilteredRecipes_When_FilteredByStatus")
|
||||||
|
void should_ReturnFilteredRecipes_When_FilteredByStatus() {
|
||||||
|
var activeRecipes = List.of(recipeWithStatus("r2", RecipeStatus.ACTIVE));
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(true);
|
||||||
|
when(recipeRepository.findByStatus(RecipeStatus.ACTIVE)).thenReturn(Result.success(activeRecipes));
|
||||||
|
|
||||||
|
var result = listRecipes.executeByStatus(RecipeStatus.ACTIVE, performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
assertThat(result.unsafeGetValue().getFirst().status()).isEqualTo(RecipeStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||||
|
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(false);
|
||||||
|
|
||||||
|
var result = listRecipes.execute(performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.Unauthorized.class);
|
||||||
|
verify(recipeRepository, never()).findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermissionForStatusFilter")
|
||||||
|
void should_FailWithUnauthorized_When_ActorLacksPermissionForStatusFilter() {
|
||||||
|
when(authPort.can(performedBy, ProductionAction.RECIPE_READ)).thenReturn(false);
|
||||||
|
|
||||||
|
var result = listRecipes.executeByStatus(RecipeStatus.ACTIVE, performedBy);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.Unauthorized.class);
|
||||||
|
verify(recipeRepository, never()).findByStatus(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package de.effigenix.infrastructure.production.web;
|
||||||
|
|
||||||
|
import de.effigenix.domain.usermanagement.RoleName;
|
||||||
|
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||||
|
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||||
|
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@DisplayName("GetRecipe Integration Tests")
|
||||||
|
class GetRecipeIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private String adminToken;
|
||||||
|
private String viewerToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
|
||||||
|
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||||
|
|
||||||
|
UserEntity admin = createUser("recipe.admin", "recipe.admin@test.com", Set.of(adminRole), "BRANCH-01");
|
||||||
|
UserEntity viewer = createUser("recipe.viewer", "recipe.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
|
||||||
|
|
||||||
|
adminToken = generateToken(admin.getId(), "recipe.admin", "RECIPE_WRITE,RECIPE_READ");
|
||||||
|
viewerToken = generateToken(viewer.getId(), "recipe.viewer", "USER_READ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Rezept per ID abrufen → 200 mit vollständigen Daten")
|
||||||
|
void getRecipe_existingId_returns200WithFullDetails() throws Exception {
|
||||||
|
String recipeId = createRecipe();
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/recipes/{id}", recipeId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id").value(recipeId))
|
||||||
|
.andExpect(jsonPath("$.name").value("Bratwurst"))
|
||||||
|
.andExpect(jsonPath("$.version").value(1))
|
||||||
|
.andExpect(jsonPath("$.ingredients").isArray())
|
||||||
|
.andExpect(jsonPath("$.productionSteps").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nicht existierendes Rezept → 404")
|
||||||
|
void getRecipe_nonExistentId_returns404() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes/{id}", "nonexistent-id")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("RECIPE_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Rezept abrufen ohne RECIPE_READ → 403")
|
||||||
|
void getRecipe_withoutPermission_returns403() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes/{id}", "any-id")
|
||||||
|
.header("Authorization", "Bearer " + viewerToken))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Rezept abrufen ohne Token → 401")
|
||||||
|
void getRecipe_withoutToken_returns401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes/{id}", "any-id"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createRecipe() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"name": "Bratwurst",
|
||||||
|
"version": 1,
|
||||||
|
"type": "FINISHED_PRODUCT",
|
||||||
|
"description": "Testrezept",
|
||||||
|
"yieldPercentage": 85,
|
||||||
|
"shelfLifeDays": 14,
|
||||||
|
"outputQuantity": "100",
|
||||||
|
"outputUom": "KILOGRAM"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = mockMvc.perform(post("/api/recipes")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(json))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
package de.effigenix.infrastructure.production.web;
|
||||||
|
|
||||||
|
import de.effigenix.domain.usermanagement.RoleName;
|
||||||
|
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||||
|
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||||
|
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@DisplayName("ListRecipes Integration Tests")
|
||||||
|
class ListRecipesIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private String adminToken;
|
||||||
|
private String viewerToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
|
||||||
|
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||||
|
|
||||||
|
UserEntity admin = createUser("recipe.admin", "recipe.admin@test.com", Set.of(adminRole), "BRANCH-01");
|
||||||
|
UserEntity viewer = createUser("recipe.viewer", "recipe.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
|
||||||
|
|
||||||
|
adminToken = generateToken(admin.getId(), "recipe.admin", "RECIPE_WRITE,RECIPE_READ");
|
||||||
|
viewerToken = generateToken(viewer.getId(), "recipe.viewer", "USER_READ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Alle Rezepte auflisten → 200 mit Summary-Format")
|
||||||
|
void listRecipes_returnsAllWithSummaryFormat() throws Exception {
|
||||||
|
createRecipe("Bratwurst", 1);
|
||||||
|
createRecipe("Leberwurst", 1);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/recipes")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(2)))
|
||||||
|
.andExpect(jsonPath("$[0].ingredientCount").isNumber())
|
||||||
|
.andExpect(jsonPath("$[0].stepCount").isNumber())
|
||||||
|
.andExpect(jsonPath("$[0].ingredients").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$[0].productionSteps").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Rezepte nach Status filtern → 200 nur passende")
|
||||||
|
void listRecipes_filteredByStatus_returnsOnlyMatching() throws Exception {
|
||||||
|
String recipeId = createRecipe("Bratwurst", 1);
|
||||||
|
createRecipe("Leberwurst", 1);
|
||||||
|
|
||||||
|
// Zutat hinzufügen und aktivieren
|
||||||
|
addIngredient(recipeId);
|
||||||
|
activateRecipe(recipeId);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/recipes")
|
||||||
|
.param("status", "ACTIVE")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$[0].name").value("Bratwurst"))
|
||||||
|
.andExpect(jsonPath("$[0].status").value("ACTIVE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Ungültiger Status-Parameter → 400")
|
||||||
|
void listRecipes_invalidStatus_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes")
|
||||||
|
.param("status", "INVALID")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("RECIPE_VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Leere Liste → 200 mit leerem Array")
|
||||||
|
void listRecipes_empty_returns200WithEmptyArray() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Rezepte auflisten ohne RECIPE_READ → 403")
|
||||||
|
void listRecipes_withoutPermission_returns403() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes")
|
||||||
|
.header("Authorization", "Bearer " + viewerToken))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Rezepte auflisten ohne Token → 401")
|
||||||
|
void listRecipes_withoutToken_returns401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/recipes"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createRecipe(String name, int version) throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"name": "%s",
|
||||||
|
"version": %d,
|
||||||
|
"type": "FINISHED_PRODUCT",
|
||||||
|
"description": "Testrezept",
|
||||||
|
"yieldPercentage": 85,
|
||||||
|
"shelfLifeDays": 14,
|
||||||
|
"outputQuantity": "100",
|
||||||
|
"outputUom": "KILOGRAM"
|
||||||
|
}
|
||||||
|
""".formatted(name, version);
|
||||||
|
|
||||||
|
var result = mockMvc.perform(post("/api/recipes")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(json))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addIngredient(String recipeId) throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"position": 1,
|
||||||
|
"articleId": "article-123",
|
||||||
|
"quantity": "5.5",
|
||||||
|
"uom": "KILOGRAM",
|
||||||
|
"substitutable": false
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(json))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void activateRecipe(String recipeId) throws Exception {
|
||||||
|
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue