1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:00:23 +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:
Sebastian Frick 2026-02-19 22:24:47 +01:00
parent 6feb3a9f1c
commit f2003a3093
12 changed files with 661 additions and 0 deletions

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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();
}
}

View file

@ -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());
}
}