diff --git a/backend/src/main/java/de/effigenix/application/production/GetRecipe.java b/backend/src/main/java/de/effigenix/application/production/GetRecipe.java new file mode 100644 index 0000000..44626ee --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/GetRecipe.java @@ -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 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()); + } + } + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/ListRecipes.java b/backend/src/main/java/de/effigenix/application/production/ListRecipes.java new file mode 100644 index 0000000..3a5d825 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/ListRecipes.java @@ -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> 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> 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); } + } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java b/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java index a8c3d9a..20aedd1 100644 --- a/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java @@ -17,4 +17,6 @@ public interface RecipeRepository { Result delete(Recipe recipe); Result existsByNameAndVersion(String name, int version); + + Result> findByStatus(RecipeStatus status); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index d6d9e3b..b167e79 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -5,6 +5,8 @@ import de.effigenix.application.production.ArchiveRecipe; import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; 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.RemoveRecipeIngredient; import de.effigenix.domain.production.RecipeRepository; @@ -40,6 +42,16 @@ public class ProductionUseCaseConfiguration { 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 public ActivateRecipe activateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { return new ActivateRecipe(recipeRepository, authorizationPort); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java index 33850b9..e42371e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java @@ -78,6 +78,19 @@ public class JpaRecipeRepository implements RecipeRepository { } } + @Override + public Result> findByStatus(RecipeStatus status) { + try { + List 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 public Result existsByNameAndVersion(String name, int version) { try { diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java index ec19f1c..cf2ae43 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java @@ -5,6 +5,8 @@ import de.effigenix.application.production.ArchiveRecipe; import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; 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.RemoveRecipeIngredient; 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.RemoveRecipeIngredientCommand; 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.AddRecipeIngredientRequest; import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest; import de.effigenix.infrastructure.production.web.dto.RecipeResponse; +import de.effigenix.infrastructure.production.web.dto.RecipeSummaryResponse; import de.effigenix.shared.security.ActorId; import io.swagger.v3.oas.annotations.security.SecurityRequirement; 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.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/api/recipes") @SecurityRequirement(name = "Bearer Authentication") @@ -40,6 +47,8 @@ public class RecipeController { private static final Logger logger = LoggerFactory.getLogger(RecipeController.class); private final CreateRecipe createRecipe; + private final GetRecipe getRecipe; + private final ListRecipes listRecipes; private final AddRecipeIngredient addRecipeIngredient; private final RemoveRecipeIngredient removeRecipeIngredient; private final AddProductionStep addProductionStep; @@ -48,6 +57,8 @@ public class RecipeController { private final ArchiveRecipe archiveRecipe; public RecipeController(CreateRecipe createRecipe, + GetRecipe getRecipe, + ListRecipes listRecipes, AddRecipeIngredient addRecipeIngredient, RemoveRecipeIngredient removeRecipeIngredient, AddProductionStep addProductionStep, @@ -55,6 +66,8 @@ public class RecipeController { ActivateRecipe activateRecipe, ArchiveRecipe archiveRecipe) { this.createRecipe = createRecipe; + this.getRecipe = getRecipe; + this.listRecipes = listRecipes; this.addRecipeIngredient = addRecipeIngredient; this.removeRecipeIngredient = removeRecipeIngredient; this.addProductionStep = addProductionStep; @@ -63,6 +76,54 @@ public class RecipeController { this.archiveRecipe = archiveRecipe; } + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('RECIPE_READ')") + public ResponseEntity 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> 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 @PreAuthorize("hasAuthority('RECIPE_WRITE')") public ResponseEntity createRecipe( diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java new file mode 100644 index 0000000..49171d3 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java index ee23508..38611a3 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.stub; import de.effigenix.domain.production.Recipe; import de.effigenix.domain.production.RecipeId; import de.effigenix.domain.production.RecipeRepository; +import de.effigenix.domain.production.RecipeStatus; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; import org.springframework.context.annotation.Profile; @@ -42,4 +43,9 @@ public class StubRecipeRepository implements RecipeRepository { public Result existsByNameAndVersion(String name, int version) { return Result.failure(STUB_ERROR); } + + @Override + public Result> findByStatus(RecipeStatus status) { + return Result.failure(STUB_ERROR); + } } diff --git a/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java b/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java new file mode 100644 index 0000000..3804178 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java b/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java new file mode 100644 index 0000000..f46df29 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java new file mode 100644 index 0000000..334c9c4 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java new file mode 100644 index 0000000..43457fe --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java @@ -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()); + } +}