1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:39:35 +01:00

test: Blackbox-Integrationstests für dokumentierte Domain-Invarianten

Neue Testdateien:
- RecipeLifecycleIntegrationTest (7 Tests): Aktivierung ohne Zutaten,
  Modifikation aktiver Rezepte, Archivierung, Duplikat-Prüfungen
- RoleControllerIntegrationTest (3 Tests): GET /api/roles mit/ohne
  Berechtigung und ohne Token

Erweiterte Testdateien:
- BatchControllerIntegrationTest (+2): start/cancel von COMPLETED
- StockControllerIntegrationTest (+3): removeBatch und FEFO-Allokation
- StorageLocationControllerIntegrationTest (+2): active=false Filter
- ArticleControllerIntegrationTest (+4): updatePrice, removeSupplier,
  filterByCategory, updateArticle
- CustomerControllerIntegrationTest (+3): removeFrameContract,
  updateCustomer, duplicateLineItems
This commit is contained in:
Sebastian Frick 2026-02-24 09:36:24 +01:00
parent 11bda32ffc
commit d7fcc946e7
7 changed files with 694 additions and 4 deletions

View file

@ -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) ==================== // ==================== Bestandsposition abfragen (getStock) ====================
@Nested @Nested

View file

@ -525,8 +525,8 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest {
} }
@Test @Test
@DisplayName("Lagerorte nach active filtern → 200") @DisplayName("Lagerorte nach active=true filtern → 200 nur aktive")
void listStorageLocations_filterByActive() throws Exception { void listStorageLocations_filterByActiveTrue() throws Exception {
String activeId = createAndReturnId("Aktiv", "DRY_STORAGE", null, null); String activeId = createAndReturnId("Aktiv", "DRY_STORAGE", null, null);
String inactiveId = createAndReturnId("Inaktiv", "COLD_ROOM", null, null); String inactiveId = createAndReturnId("Inaktiv", "COLD_ROOM", null, null);
@ -544,8 +544,26 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest {
} }
@Test @Test
@DisplayName("Lagerorte mit storageType und active filtern → 200") @DisplayName("Lagerorte nach active=false filtern → 200 nur inaktive")
void listStorageLocations_filterByStorageTypeAndActive() throws Exception { 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); createAndReturnId("Kühl Aktiv", "COLD_ROOM", null, null);
String inactiveId = createAndReturnId("Kühl Inaktiv", "COLD_ROOM", null, null); String inactiveId = createAndReturnId("Kühl Inaktiv", "COLD_ROOM", null, null);
createAndReturnId("Trocken Aktiv", "DRY_STORAGE", 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")); .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 @Test
@DisplayName("Lagerorte mit ungültigem StorageType filtern → 400") @DisplayName("Lagerorte mit ungültigem StorageType filtern → 400")
void listStorageLocations_invalidStorageType_returns400() throws Exception { void listStorageLocations_invalidStorageType_returns400() throws Exception {

View file

@ -324,6 +324,91 @@ class ArticleControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(status().isNotFound()); .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 ==================== // ==================== Hilfsmethoden ====================
private String createCategory(String name) throws Exception { private String createCategory(String name) throws Exception {
@ -349,6 +434,18 @@ class ArticleControllerIntegrationTest extends AbstractIntegrationTest {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); 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 { private String createSupplier(String name) throws Exception {
var request = new CreateSupplierRequest( var request = new CreateSupplierRequest(
name, "+49 30 12345", name, "+49 30 12345",

View file

@ -400,6 +400,78 @@ class CustomerControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.frameContract.deliveryRhythm").value("WEEKLY")); .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 ==================== // ==================== TC-AUTH ====================
@Test @Test

View file

@ -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 ==================== // ==================== Hilfsmethoden ====================
private String createActiveRecipe() throws Exception { private String createActiveRecipe() throws Exception {

View file

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

View file

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