diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java index fe0a1d0..c75d5b6 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java @@ -727,6 +727,94 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== Charge entnehmen (removeBatch) ==================== + + @Nested + @DisplayName("POST /{stockId}/batches/{batchId}/remove – Charge entnehmen") + class RemoveBatch { + + @Test + @DisplayName("Teilentnahme → 200, Restmenge korrekt") + void removeBatch_partialQuantity_returns200() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); // 10 KILOGRAM + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/remove", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"quantityAmount": "3", "quantityUnit": "KILOGRAM"} + """)) + .andExpect(status().isOk()); + + // Restmenge prüfen + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalQuantity").value(7)) + .andExpect(jsonPath("$.availableQuantity").value(7)); + } + + @Test + @DisplayName("Entnahme über verfügbare Menge → 409") + void removeBatch_moreThanAvailable_returns409() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); // 10 KILOGRAM + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/remove", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"quantityAmount": "999", "quantityUnit": "KILOGRAM"} + """)) + .andExpect(status().isConflict()); + } + } + + // ==================== FEFO-Reservierung ==================== + + @Nested + @DisplayName("FEFO-Allokation bei Reservierung") + class FefoReservation { + + @Test + @DisplayName("Reservierung allokiert Charge mit frühestem Ablaufdatum zuerst (FEFO)") + void reserve_fefoOrder_allocatesEarliestFirst() throws Exception { + String stockId = createStock(); + + // Charge mit spätem Ablaufdatum zuerst einbuchen + var lateBatch = new AddStockBatchRequest( + "BATCH-LATE", "PRODUCED", "10", "KILOGRAM", "2027-12-31"); + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(lateBatch))) + .andExpect(status().isCreated()); + + // Charge mit frühem Ablaufdatum danach einbuchen + var earlyBatch = new AddStockBatchRequest( + "BATCH-EARLY", "PRODUCED", "10", "KILOGRAM", "2026-06-30"); + var earlyResult = mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(earlyBatch))) + .andExpect(status().isCreated()) + .andReturn(); + String earlyBatchId = objectMapper.readTree(earlyResult.getResponse().getContentAsString()).get("id").asText(); + + // Reservierung: 5 kg – soll aus BATCH-EARLY (früheres Ablaufdatum) allokiert werden + var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-FEFO", "5", "KILOGRAM", "NORMAL"); + mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.allocations.length()").value(1)) + .andExpect(jsonPath("$.allocations[0].stockBatchId").value(earlyBatchId)) + .andExpect(jsonPath("$.allocations[0].allocatedQuantityAmount").value(5)); + } + } + // ==================== Bestandsposition abfragen (getStock) ==================== @Nested diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java index c926bcd..f5cb332 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java @@ -525,8 +525,8 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest { } @Test - @DisplayName("Lagerorte nach active filtern → 200") - void listStorageLocations_filterByActive() throws Exception { + @DisplayName("Lagerorte nach active=true filtern → 200 nur aktive") + void listStorageLocations_filterByActiveTrue() throws Exception { String activeId = createAndReturnId("Aktiv", "DRY_STORAGE", null, null); String inactiveId = createAndReturnId("Inaktiv", "COLD_ROOM", null, null); @@ -544,8 +544,26 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest { } @Test - @DisplayName("Lagerorte mit storageType und active filtern → 200") - void listStorageLocations_filterByStorageTypeAndActive() throws Exception { + @DisplayName("Lagerorte nach active=false filtern → 200 nur inaktive") + void listStorageLocations_filterByActiveFalse() throws Exception { + createAndReturnId("Aktiv", "DRY_STORAGE", null, null); + String inactiveId = createAndReturnId("Inaktiv", "COLD_ROOM", null, null); + + mockMvc.perform(patch("/api/inventory/storage-locations/" + inactiveId + "/deactivate") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/inventory/storage-locations") + .param("active", "false") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Inaktiv")); + } + + @Test + @DisplayName("Lagerorte mit storageType und active=true filtern → 200") + void listStorageLocations_filterByStorageTypeAndActiveTrue() throws Exception { createAndReturnId("Kühl Aktiv", "COLD_ROOM", null, null); String inactiveId = createAndReturnId("Kühl Inaktiv", "COLD_ROOM", null, null); createAndReturnId("Trocken Aktiv", "DRY_STORAGE", null, null); @@ -563,6 +581,26 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(jsonPath("$[0].name").value("Kühl Aktiv")); } + @Test + @DisplayName("Lagerorte mit storageType und active=false filtern → 200 nur inaktive des Typs") + void listStorageLocations_filterByStorageTypeAndActiveFalse() throws Exception { + createAndReturnId("Kühl Aktiv", "COLD_ROOM", null, null); + String inactiveId = createAndReturnId("Kühl Inaktiv", "COLD_ROOM", null, null); + createAndReturnId("Trocken Aktiv", "DRY_STORAGE", null, null); + + mockMvc.perform(patch("/api/inventory/storage-locations/" + inactiveId + "/deactivate") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/inventory/storage-locations") + .param("storageType", "COLD_ROOM") + .param("active", "false") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Kühl Inaktiv")); + } + @Test @DisplayName("Lagerorte mit ungültigem StorageType filtern → 400") void listStorageLocations_invalidStorageType_returns400() throws Exception { diff --git a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java index 4f85d54..4fedbbb 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java @@ -324,6 +324,91 @@ class ArticleControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(status().isNotFound()); } + // ==================== Verkaufseinheit-Preis aktualisieren ==================== + + @Test + @DisplayName("Verkaufseinheit-Preis aktualisieren → 200") + void updateSalesUnitPrice_returns200() throws Exception { + String articleId = createArticle("Preis-Test", "PT-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + MvcResult getResult = mockMvc.perform(get("/api/articles/{id}", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andReturn(); + String salesUnitId = objectMapper.readTree(getResult.getResponse().getContentAsString()) + .get("salesUnits").get(0).get("id").asText(); + + var priceRequest = new UpdateSalesUnitPriceRequest(new BigDecimal("3.49")); + + mockMvc.perform(put("/api/articles/{id}/sales-units/{suId}/price", articleId, salesUnitId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(priceRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.salesUnits[0].price").value(3.49)); + } + + // ==================== Lieferant entfernen ==================== + + @Test + @DisplayName("Lieferant vom Artikel entfernen → 204") + void removeSupplier_returns204() throws Exception { + String articleId = createArticle("Supplier-Test", "ST-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + String supplierId = createSupplier("Entfern AG"); + + // Zuweisen + mockMvc.perform(post("/api/articles/{id}/suppliers", articleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AssignSupplierRequest(supplierId)))) + .andExpect(status().isOk()); + + // Entfernen + mockMvc.perform(delete("/api/articles/{id}/suppliers/{supplierId}", articleId, supplierId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + // Prüfen + mockMvc.perform(get("/api/articles/{id}", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.supplierIds", hasSize(0))); + } + + // ==================== Artikel nach Kategorie filtern ==================== + + @Test + @DisplayName("Artikel nach categoryId filtern → nur passende zurückgegeben") + void filterByCategory_returnsOnlyMatching() throws Exception { + String otherCategoryId = createCategory("Milchprodukte"); + createArticle("Äpfel", "FI-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + createArticleInCategory("Milch", "MI-001", Unit.KG, PriceModel.WEIGHT_BASED, "1.29", otherCategoryId); + + mockMvc.perform(get("/api/articles") + .param("categoryId", otherCategoryId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Milch")); + } + + // ==================== Artikel aktualisieren ==================== + + @Test + @DisplayName("Artikel Name und Kategorie ändern → 200") + void updateArticle_returns200() throws Exception { + String articleId = createArticle("Alt Name", "UP-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + String newCategoryId = createCategory("Neue Kategorie"); + + var request = new UpdateArticleRequest("Neuer Name", newCategoryId); + + mockMvc.perform(put("/api/articles/{id}", articleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Neuer Name")); + } + // ==================== Hilfsmethoden ==================== private String createCategory(String name) throws Exception { @@ -349,6 +434,18 @@ class ArticleControllerIntegrationTest extends AbstractIntegrationTest { return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); } + private String createArticleInCategory(String name, String number, Unit unit, PriceModel priceModel, String price, String catId) throws Exception { + var request = new CreateArticleRequest( + name, number, catId, unit, priceModel, new BigDecimal(price)); + MvcResult result = mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + private String createSupplier(String name) throws Exception { var request = new CreateSupplierRequest( name, "+49 30 12345", diff --git a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java index f38e6a5..7638871 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java @@ -400,6 +400,78 @@ class CustomerControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.frameContract.deliveryRhythm").value("WEEKLY")); } + // ==================== Rahmenvertrag entfernen ==================== + + @Test + @DisplayName("Rahmenvertrag entfernen → 204, kein FrameContract mehr vorhanden") + void removeFrameContract_returns204() throws Exception { + String b2bId = createB2bCustomer("Vertrags GmbH"); + String articleId = createArticle(); + + var contract = new SetFrameContractRequest( + LocalDate.of(2025, 1, 1), LocalDate.of(2025, 12, 31), + DeliveryRhythm.WEEKLY, + List.of(new SetFrameContractRequest.LineItem( + articleId, new BigDecimal("1.50"), new BigDecimal("100"), Unit.KG))); + + mockMvc.perform(put("/api/customers/{id}/frame-contract", b2bId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(contract))) + .andExpect(status().isOk()); + + mockMvc.perform(delete("/api/customers/{id}/frame-contract", b2bId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/customers/{id}", b2bId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.frameContract").isEmpty()); + } + + // ==================== Kunden aktualisieren ==================== + + @Test + @DisplayName("Kundenstammdaten aktualisieren → 200") + void updateCustomer_returns200() throws Exception { + String customerId = createB2cCustomer("Alt Name"); + + var request = new UpdateCustomerRequest( + "Neuer Name", "Neue Str.", "5", "20095", "Hamburg", "DE", + "+49 40 1234", null, null, null, null); + + mockMvc.perform(put("/api/customers/{id}", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Neuer Name")) + .andExpect(jsonPath("$.billingAddress.city").value("Hamburg")); + } + + // ==================== Doppelte LineItems im Rahmenvertrag ==================== + + @Test + @DisplayName("Rahmenvertrag mit doppelten ArticleId-LineItems → 400") + void setFrameContract_duplicateLineItems_returns400() throws Exception { + String b2bId = createB2bCustomer("Duplikat GmbH"); + String articleId = createArticle(); + + var contract = new SetFrameContractRequest( + LocalDate.of(2025, 1, 1), LocalDate.of(2025, 12, 31), + DeliveryRhythm.WEEKLY, + List.of( + new SetFrameContractRequest.LineItem(articleId, new BigDecimal("1.50"), new BigDecimal("100"), Unit.KG), + new SetFrameContractRequest.LineItem(articleId, new BigDecimal("2.00"), new BigDecimal("200"), Unit.KG))); + + mockMvc.perform(put("/api/customers/{id}/frame-contract", b2bId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(contract))) + .andExpect(status().isBadRequest()); + } + // ==================== TC-AUTH ==================== @Test diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java index 80768a1..084fecd 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java @@ -670,6 +670,122 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== POST /api/production/batches/{id}/start – ungültige Übergänge ==================== + + @Nested + @DisplayName("POST /api/production/batches/{id}/start – Charge starten") + class StartBatchEndpoint { + + private String startToken; + + @BeforeEach + void setUpStartToken() { + startToken = generateToken(java.util.UUID.randomUUID().toString(), "start.admin", + "BATCH_WRITE,BATCH_READ,BATCH_COMPLETE,BATCH_CANCEL,RECIPE_WRITE,RECIPE_READ"); + } + + @Test + @DisplayName("COMPLETED Charge starten → 409") + void startBatch_fromCompleted_returns409() throws Exception { + String recipeId = createActiveRecipeWith(startToken); + + // Plan batch + var planRequest = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + var planResult = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(planRequest))) + .andExpect(status().isCreated()) + .andReturn(); + String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText(); + + // Start + mockMvc.perform(post("/api/production/batches/{id}/start", batchId) + .header("Authorization", "Bearer " + startToken)) + .andExpect(status().isOk()); + + // Record consumption + String consumptionJson = """ + {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "5.0", "quantityUnit": "KILOGRAM"} + """.formatted(UUID.randomUUID().toString(), UUID.randomUUID().toString()); + mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId) + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(consumptionJson)) + .andExpect(status().isCreated()); + + // Complete + String completeJson = """ + {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"} + """; + mockMvc.perform(post("/api/production/batches/{id}/complete", batchId) + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(completeJson)) + .andExpect(status().isOk()); + + // Try to start again → 409 + mockMvc.perform(post("/api/production/batches/{id}/start", batchId) + .header("Authorization", "Bearer " + startToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("COMPLETED Charge stornieren → 409") + void cancelBatch_fromCompleted_returns409() throws Exception { + String recipeId = createActiveRecipeWith(startToken); + + // Plan batch + var planRequest = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + var planResult = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(planRequest))) + .andExpect(status().isCreated()) + .andReturn(); + String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText(); + + // Start + mockMvc.perform(post("/api/production/batches/{id}/start", batchId) + .header("Authorization", "Bearer " + startToken)) + .andExpect(status().isOk()); + + // Record consumption + String consumptionJson = """ + {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "5.0", "quantityUnit": "KILOGRAM"} + """.formatted(UUID.randomUUID().toString(), UUID.randomUUID().toString()); + mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId) + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(consumptionJson)) + .andExpect(status().isCreated()); + + // Complete + String completeJson = """ + {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"} + """; + mockMvc.perform(post("/api/production/batches/{id}/complete", batchId) + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(completeJson)) + .andExpect(status().isOk()); + + // Try to cancel → 409 + String cancelJson = """ + {"reason": "Nachträgliche Stornierung"} + """; + mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId) + .header("Authorization", "Bearer " + startToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION")); + } + } + // ==================== Hilfsmethoden ==================== private String createActiveRecipe() throws Exception { diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/RecipeLifecycleIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/RecipeLifecycleIntegrationTest.java new file mode 100644 index 0000000..b91df08 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/RecipeLifecycleIntegrationTest.java @@ -0,0 +1,220 @@ +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.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.Set; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("Recipe Lifecycle Integration Tests") +class RecipeLifecycleIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + + @BeforeEach + void setUp() { + RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin"); + UserEntity admin = createUser("recipe.admin", "recipe.admin@test.com", Set.of(adminRole), "BRANCH-01"); + adminToken = generateToken(admin.getId(), "recipe.admin", "RECIPE_WRITE,RECIPE_READ"); + } + + // ==================== Aktivierung ==================== + + @Nested + @DisplayName("POST /api/recipes/{id}/activate – Rezept aktivieren") + class ActivateRecipe { + + @Test + @DisplayName("Rezept ohne Zutaten aktivieren → 400") + void activateRecipe_withoutIngredients_returns400() throws Exception { + String recipeId = createDraftRecipe(); + + mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()); + } + } + + // ==================== Modifikation aktiver Rezepte ==================== + + @Nested + @DisplayName("Modifikation aktiver Rezepte") + class ModifyActiveRecipe { + + @Test + @DisplayName("Zutat zu aktivem Rezept hinzufügen → 409") + void addIngredient_toActiveRecipe_returns409() throws Exception { + String recipeId = createActiveRecipe(); + + String ingredientJson = """ + {"position": 2, "articleId": "%s", "quantity": "3.0", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("Schritt zu aktivem Rezept hinzufügen → 409") + void addStep_toActiveRecipe_returns409() throws Exception { + String recipeId = createActiveRecipe(); + + String stepJson = """ + {"stepNumber": 1, "description": "Mischen", "durationMinutes": 15} + """; + + mockMvc.perform(post("/api/recipes/{id}/steps", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(stepJson)) + .andExpect(status().isConflict()); + } + } + + // ==================== Archivierung ==================== + + @Nested + @DisplayName("POST /api/recipes/{id}/archive – Rezept archivieren") + class ArchiveRecipe { + + @Test + @DisplayName("Aktives Rezept archivieren → 200, Status ARCHIVED") + void archiveRecipe_fromActive_returns200() throws Exception { + String recipeId = createActiveRecipe(); + + mockMvc.perform(post("/api/recipes/{id}/archive", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ARCHIVED")); + } + + @Test + @DisplayName("Draft-Rezept archivieren → 409 (ungültiger Übergang)") + void archiveRecipe_fromDraft_returns409() throws Exception { + String recipeId = createDraftRecipe(); + + mockMvc.perform(post("/api/recipes/{id}/archive", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isConflict()); + } + } + + // ==================== Duplikate ==================== + + @Nested + @DisplayName("Duplikat-Prüfungen") + class DuplicateChecks { + + @Test + @DisplayName("Zutat mit doppelter Position → 409") + void addIngredient_duplicatePosition_returns409() throws Exception { + String recipeId = createDraftRecipe(); + + String ingredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isCreated()); + + String duplicateIngredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "3.0", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(duplicateIngredientJson)) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("Schritt mit doppelter Schrittnummer → 409") + void addStep_duplicateStepNumber_returns409() throws Exception { + String recipeId = createDraftRecipe(); + + String stepJson = """ + {"stepNumber": 1, "description": "Mischen", "durationMinutes": 15} + """; + + mockMvc.perform(post("/api/recipes/{id}/steps", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(stepJson)) + .andExpect(status().isCreated()); + + String duplicateStepJson = """ + {"stepNumber": 1, "description": "Anderer Schritt", "durationMinutes": 10} + """; + + mockMvc.perform(post("/api/recipes/{id}/steps", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(duplicateStepJson)) + .andExpect(status().isConflict()); + } + } + + // ==================== Hilfsmethoden ==================== + + private String createDraftRecipe() throws Exception { + String json = """ + { + "name": "Test-Rezept-%s", + "version": 1, + "type": "FINISHED_PRODUCT", + "description": "Testrezept", + "yieldPercentage": 85, + "shelfLifeDays": 14, + "outputQuantity": "100", + "outputUom": "KILOGRAM", + "articleId": "article-123" + } + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + 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 String createActiveRecipe() throws Exception { + String recipeId = createDraftRecipe(); + + String ingredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return recipeId; + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/usermanagement/web/RoleControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/usermanagement/web/RoleControllerIntegrationTest.java new file mode 100644 index 0000000..310a695 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/usermanagement/web/RoleControllerIntegrationTest.java @@ -0,0 +1,59 @@ +package de.effigenix.infrastructure.usermanagement.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 java.util.Set; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("Role Controller Integration Tests") +class RoleControllerIntegrationTest 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("role.admin", "role.admin@test.com", Set.of(adminRole), "BRANCH-01"); + UserEntity viewer = createUser("role.viewer", "role.viewer@test.com", Set.of(viewerRole), "BRANCH-01"); + + adminToken = generateToken(admin.getId(), "role.admin", "ROLE_READ"); + viewerToken = generateToken(viewer.getId(), "role.viewer", "USER_READ"); + } + + @Test + @DisplayName("Rollen auflisten mit ROLE_READ → 200") + void listRoles_withPermission_returns200() throws Exception { + mockMvc.perform(get("/api/roles") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(2)))); + } + + @Test + @DisplayName("Rollen auflisten ohne ROLE_READ → 403") + void listRoles_withoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/roles") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Rollen auflisten ohne Token → 401") + void listRoles_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/roles")) + .andExpect(status().isUnauthorized()); + } +}