From 6010820944ce4792acc8efdb97262b5ee018c2df Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 12:42:25 +0100 Subject: [PATCH] feat(inventory): Lagerorte abfragen mit Filter nach Typ und Status (#3) Query UseCase: ListStorageLocations mit optionalen Filtern storageType und active GET /api/inventory/storage-locations mit STOCK_READ oder STOCK_WRITE Tests: 10 neue Integrationstests (35 gesamt) --- .../inventory/ListStorageLocations.java | 51 +++++++ .../config/InventoryUseCaseConfiguration.java | 6 + .../controller/StorageLocationController.java | 26 +++- ...rageLocationControllerIntegrationTest.java | 143 +++++++++++++++++- 4 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/ListStorageLocations.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListStorageLocations.java b/backend/src/main/java/de/effigenix/application/inventory/ListStorageLocations.java new file mode 100644 index 0000000..7edc912 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ListStorageLocations.java @@ -0,0 +1,51 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Transactional(readOnly = true) +public class ListStorageLocations { + + private final StorageLocationRepository storageLocationRepository; + + public ListStorageLocations(StorageLocationRepository storageLocationRepository) { + this.storageLocationRepository = storageLocationRepository; + } + + public Result> execute(String storageType, Boolean active) { + if (storageType != null) { + return findByStorageType(storageType, active); + } + if (Boolean.TRUE.equals(active)) { + return mapResult(storageLocationRepository.findActive()); + } + return mapResult(storageLocationRepository.findAll()); + } + + private Result> findByStorageType(String storageType, Boolean active) { + StorageType type; + try { + type = StorageType.valueOf(storageType); + } catch (IllegalArgumentException e) { + return Result.failure(new StorageLocationError.InvalidStorageType(storageType)); + } + + var result = mapResult(storageLocationRepository.findByStorageType(type)); + if (result.isSuccess() && Boolean.TRUE.equals(active)) { + return Result.success(result.unsafeGetValue().stream().filter(StorageLocation::active).toList()); + } + return result; + } + + private Result> mapResult( + Result> result) { + return switch (result) { + case Result.Failure(var err) -> Result.failure(new StorageLocationError.RepositoryFailure(err.message())); + case Result.Success(var locations) -> Result.success(locations); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index 2a80360..3845f77 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation; +import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.domain.inventory.StorageLocationRepository; import org.springframework.context.annotation.Bean; @@ -32,4 +33,9 @@ public class InventoryUseCaseConfiguration { public ActivateStorageLocation activateStorageLocation(StorageLocationRepository storageLocationRepository) { return new ActivateStorageLocation(storageLocationRepository); } + + @Bean + public ListStorageLocations listStorageLocations(StorageLocationRepository storageLocationRepository) { + return new ListStorageLocations(storageLocationRepository); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java index 92c4cdc..e2da58f 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.web.controller; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation; +import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.inventory.command.CreateStorageLocationCommand; import de.effigenix.application.inventory.command.UpdateStorageLocationCommand; @@ -22,6 +23,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/inventory/storage-locations") @SecurityRequirement(name = "Bearer Authentication") @@ -34,17 +37,38 @@ public class StorageLocationController { private final UpdateStorageLocation updateStorageLocation; private final DeactivateStorageLocation deactivateStorageLocation; private final ActivateStorageLocation activateStorageLocation; + private final ListStorageLocations listStorageLocations; public StorageLocationController( CreateStorageLocation createStorageLocation, UpdateStorageLocation updateStorageLocation, DeactivateStorageLocation deactivateStorageLocation, - ActivateStorageLocation activateStorageLocation + ActivateStorageLocation activateStorageLocation, + ListStorageLocations listStorageLocations ) { this.createStorageLocation = createStorageLocation; this.updateStorageLocation = updateStorageLocation; this.deactivateStorageLocation = deactivateStorageLocation; this.activateStorageLocation = activateStorageLocation; + this.listStorageLocations = listStorageLocations; + } + + @GetMapping + @PreAuthorize("hasAuthority('STOCK_READ') or hasAuthority('STOCK_WRITE')") + public ResponseEntity> listStorageLocations( + @RequestParam(value = "storageType", required = false) String storageType, + @RequestParam(value = "active", required = false) Boolean active + ) { + var result = listStorageLocations.execute(storageType, active); + + if (result.isFailure()) { + throw new StorageLocationDomainErrorException(result.unsafeGetError()); + } + + var response = result.unsafeGetValue().stream() + .map(StorageLocationResponse::from) + .toList(); + return ResponseEntity.ok(response); } @PostMapping 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 1fac380..dde8392 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 @@ -69,6 +69,7 @@ class StorageLocationControllerIntegrationTest { private long jwtExpiration; private String adminToken; + private String readerToken; private String viewerToken; @BeforeEach @@ -87,13 +88,20 @@ class StorageLocationControllerIntegrationTest { passwordEncoder.encode("Pass123"), Set.of(adminRole), "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + String readerId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + readerId, "inv.reader", "inv.reader@test.com", + passwordEncoder.encode("Pass123"), Set.of(viewerRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + String viewerId = UUID.randomUUID().toString(); userRepository.save(new UserEntity( viewerId, "inv.viewer", "inv.viewer@test.com", passwordEncoder.encode("Pass123"), Set.of(viewerRole), "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); - adminToken = generateToken(adminId, "inv.admin", "STOCK_WRITE"); + adminToken = generateToken(adminId, "inv.admin", "STOCK_WRITE,STOCK_READ"); + readerToken = generateToken(readerId, "inv.reader", "STOCK_READ"); viewerToken = generateToken(viewerId, "inv.viewer", "USER_READ"); } @@ -467,6 +475,139 @@ class StorageLocationControllerIntegrationTest { .andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NOT_FOUND")); } + // ==================== Lagerorte abfragen (Story 1.3) ==================== + + @Test + @DisplayName("Alle Lagerorte abfragen → 200 mit Liste") + void listStorageLocations_returnsAll() throws Exception { + createAndReturnId("Lager A", "DRY_STORAGE", null, null); + createAndReturnId("Lager B", "COLD_ROOM", "-2", "8"); + + mockMvc.perform(get("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].name", containsInAnyOrder("Lager A", "Lager B"))) + .andExpect(jsonPath("$[0].id").isNotEmpty()) + .andExpect(jsonPath("$[0].storageType").isNotEmpty()) + .andExpect(jsonPath("$[0].active").isBoolean()); + } + + @Test + @DisplayName("Lagerorte nach StorageType filtern → 200") + void listStorageLocations_filterByStorageType() throws Exception { + createAndReturnId("Kühlraum", "COLD_ROOM", "-2", "8"); + createAndReturnId("Trockenlager", "DRY_STORAGE", null, null); + createAndReturnId("Gefrierschrank", "FREEZER", "-25", "-18"); + + mockMvc.perform(get("/api/inventory/storage-locations") + .param("storageType", "COLD_ROOM") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].storageType").value("COLD_ROOM")); + } + + @Test + @DisplayName("Lagerorte nach active filtern → 200") + void listStorageLocations_filterByActive() throws Exception { + String activeId = createAndReturnId("Aktiv", "DRY_STORAGE", null, null); + String inactiveId = createAndReturnId("Inaktiv", "COLD_ROOM", null, null); + + // Deaktivieren + 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", "true") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Aktiv")); + } + + @Test + @DisplayName("Lagerorte mit storageType und active filtern → 200") + void listStorageLocations_filterByStorageTypeAndActive() 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", "true") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Kühl Aktiv")); + } + + @Test + @DisplayName("Lagerorte mit ungültigem StorageType filtern → 400") + void listStorageLocations_invalidStorageType_returns400() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations") + .param("storageType", "INVALID") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_STORAGE_TYPE")); + } + + @Test + @DisplayName("Lagerorte abfragen mit STOCK_READ → 200") + void listStorageLocations_withReaderToken_returns200() throws Exception { + createAndReturnId("Reader Test", "DRY_STORAGE", null, null); + + mockMvc.perform(get("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + } + + @Test + @DisplayName("Lagerorte abfragen ohne passende Berechtigung → 403") + void listStorageLocations_withViewerToken_returns403() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Lagerorte abfragen ohne Token → 401") + void listStorageLocations_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Leere Liste wenn keine Lagerorte existieren → 200") + void listStorageLocations_empty_returns200() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @DisplayName("Lagerorte enthalten vollständige Daten (id, name, storageType, temperatureRange, active)") + void listStorageLocations_returnsCompleteData() throws Exception { + createAndReturnId("Vollständig", "COLD_ROOM", "-2", "8"); + + mockMvc.perform(get("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").isNotEmpty()) + .andExpect(jsonPath("$[0].name").value("Vollständig")) + .andExpect(jsonPath("$[0].storageType").value("COLD_ROOM")) + .andExpect(jsonPath("$[0].temperatureRange.minTemperature").value(-2)) + .andExpect(jsonPath("$[0].temperatureRange.maxTemperature").value(8)) + .andExpect(jsonPath("$[0].active").value(true)); + } + // ==================== Hilfsmethoden ==================== private String createAndReturnId(String name, String storageType, String minTemp, String maxTemp) throws Exception {