From 42c9ca9d19ec5bf1e5c9cce20272f9c6462197b6 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Mon, 23 Feb 2026 22:40:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20GET=20Endpoint=20f=C3=BCr=20?= =?UTF-8?q?einzelnen=20Lagerort=20(StorageLocation=20by=20ID)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetStorageLocation Use Case mit AuthorizationPort, GET /{id} Endpoint im Controller. 6 Unit Tests und 5 Integrationstests für alle Edge Cases. --- .../inventory/GetStorageLocation.java | 40 ++++++ .../config/InventoryUseCaseConfiguration.java | 10 +- .../controller/StorageLocationController.java | 20 +++ .../inventory/GetStorageLocationTest.java | 121 ++++++++++++++++++ ...rageLocationControllerIntegrationTest.java | 75 +++++++++++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/GetStorageLocation.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/GetStorageLocationTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/GetStorageLocation.java b/backend/src/main/java/de/effigenix/application/inventory/GetStorageLocation.java new file mode 100644 index 0000000..ca77d64 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/GetStorageLocation.java @@ -0,0 +1,40 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.StorageLocation; +import de.effigenix.domain.inventory.StorageLocationError; +import de.effigenix.domain.inventory.StorageLocationId; +import de.effigenix.domain.inventory.StorageLocationRepository; +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 GetStorageLocation { + + private final StorageLocationRepository storageLocationRepository; + private final AuthorizationPort authPort; + + public GetStorageLocation(StorageLocationRepository storageLocationRepository, AuthorizationPort authPort) { + this.storageLocationRepository = storageLocationRepository; + this.authPort = authPort; + } + + public Result execute(String storageLocationId, ActorId performedBy) { + if (!authPort.can(performedBy, InventoryAction.STOCK_READ)) { + return Result.failure(new StorageLocationError.Unauthorized("Not authorized to view storage location")); + } + + if (storageLocationId == null || storageLocationId.isBlank()) { + return Result.failure(new StorageLocationError.StorageLocationNotFound(storageLocationId)); + } + + return switch (storageLocationRepository.findById(StorageLocationId.of(storageLocationId))) { + case Result.Failure(var err) -> Result.failure(new StorageLocationError.RepositoryFailure(err.message())); + case Result.Success(var opt) -> opt + .>map(Result::success) + .orElseGet(() -> Result.failure(new StorageLocationError.StorageLocationNotFound(storageLocationId))); + }; + } +} 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 b36ebc1..a58fa64 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -13,6 +13,7 @@ import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation; +import de.effigenix.application.inventory.GetStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.usermanagement.AuditLogger; @@ -38,8 +39,8 @@ public class InventoryUseCaseConfiguration { } @Bean - public DeactivateStorageLocation deactivateStorageLocation(StorageLocationRepository storageLocationRepository) { - return new DeactivateStorageLocation(storageLocationRepository); + public DeactivateStorageLocation deactivateStorageLocation(StorageLocationRepository storageLocationRepository, StockRepository stockRepository) { + return new DeactivateStorageLocation(storageLocationRepository, stockRepository); } @Bean @@ -47,6 +48,11 @@ public class InventoryUseCaseConfiguration { return new ActivateStorageLocation(storageLocationRepository); } + @Bean + public GetStorageLocation getStorageLocation(StorageLocationRepository storageLocationRepository, AuthorizationPort authorizationPort) { + return new GetStorageLocation(storageLocationRepository, authorizationPort); + } + @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 e2da58f..54def14 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.GetStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.inventory.command.CreateStorageLocationCommand; @@ -34,6 +35,7 @@ public class StorageLocationController { private static final Logger logger = LoggerFactory.getLogger(StorageLocationController.class); private final CreateStorageLocation createStorageLocation; + private final GetStorageLocation getStorageLocation; private final UpdateStorageLocation updateStorageLocation; private final DeactivateStorageLocation deactivateStorageLocation; private final ActivateStorageLocation activateStorageLocation; @@ -41,12 +43,14 @@ public class StorageLocationController { public StorageLocationController( CreateStorageLocation createStorageLocation, + GetStorageLocation getStorageLocation, UpdateStorageLocation updateStorageLocation, DeactivateStorageLocation deactivateStorageLocation, ActivateStorageLocation activateStorageLocation, ListStorageLocations listStorageLocations ) { this.createStorageLocation = createStorageLocation; + this.getStorageLocation = getStorageLocation; this.updateStorageLocation = updateStorageLocation; this.deactivateStorageLocation = deactivateStorageLocation; this.activateStorageLocation = activateStorageLocation; @@ -71,6 +75,22 @@ public class StorageLocationController { return ResponseEntity.ok(response); } + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('STOCK_READ') or hasAuthority('STOCK_WRITE')") + public ResponseEntity getStorageLocation( + @PathVariable("id") String storageLocationId, + Authentication authentication + ) { + var actorId = extractActorId(authentication); + var result = getStorageLocation.execute(storageLocationId, actorId); + + if (result.isFailure()) { + throw new StorageLocationDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(StorageLocationResponse.from(result.unsafeGetValue())); + } + @PostMapping @PreAuthorize("hasAuthority('STOCK_WRITE')") public ResponseEntity createStorageLocation( diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetStorageLocationTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetStorageLocationTest.java new file mode 100644 index 0000000..1e84e57 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/GetStorageLocationTest.java @@ -0,0 +1,121 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +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.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetStorageLocation Use Case") +class GetStorageLocationTest { + + @Mock private StorageLocationRepository storageLocationRepository; + @Mock private AuthorizationPort authPort; + + private GetStorageLocation getStorageLocation; + private ActorId performedBy; + + @BeforeEach + void setUp() { + getStorageLocation = new GetStorageLocation(storageLocationRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private StorageLocation existingLocation(String id) { + return StorageLocation.reconstitute( + StorageLocationId.of(id), + new StorageLocationName("Kühlraum 1"), + StorageType.COLD_ROOM, + null, + true + ); + } + + @Test + @DisplayName("should return storage location when found") + void shouldReturnStorageLocationWhenFound() { + var locationId = "location-1"; + when(authPort.can(performedBy, InventoryAction.STOCK_READ)).thenReturn(true); + when(storageLocationRepository.findById(StorageLocationId.of(locationId))) + .thenReturn(Result.success(Optional.of(existingLocation(locationId)))); + + var result = getStorageLocation.execute(locationId, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().id().value()).isEqualTo(locationId); + } + + @Test + @DisplayName("should fail with StorageLocationNotFound when not found") + void shouldFailWhenNotFound() { + when(authPort.can(performedBy, InventoryAction.STOCK_READ)).thenReturn(true); + when(storageLocationRepository.findById(StorageLocationId.of("nonexistent"))) + .thenReturn(Result.success(Optional.empty())); + + var result = getStorageLocation.execute("nonexistent", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.StorageLocationNotFound.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void shouldFailWhenUnauthorized() { + when(authPort.can(performedBy, InventoryAction.STOCK_READ)).thenReturn(false); + + var result = getStorageLocation.execute("location-1", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.Unauthorized.class); + verify(storageLocationRepository, never()).findById(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails") + void shouldFailWhenRepositoryFails() { + when(authPort.can(performedBy, InventoryAction.STOCK_READ)).thenReturn(true); + when(storageLocationRepository.findById(StorageLocationId.of("location-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = getStorageLocation.execute("location-1", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with StorageLocationNotFound when id is null") + void shouldFailWhenIdIsNull() { + when(authPort.can(performedBy, InventoryAction.STOCK_READ)).thenReturn(true); + + var result = getStorageLocation.execute(null, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.StorageLocationNotFound.class); + verify(storageLocationRepository, never()).findById(any()); + } + + @Test + @DisplayName("should fail with StorageLocationNotFound when id is blank") + void shouldFailWhenIdIsBlank() { + when(authPort.can(performedBy, InventoryAction.STOCK_READ)).thenReturn(true); + + var result = getStorageLocation.execute(" ", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.StorageLocationNotFound.class); + verify(storageLocationRepository, never()).findById(any()); + } +} 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 21e9ebe..c926bcd 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 @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.web; import de.effigenix.domain.usermanagement.RoleName; import de.effigenix.infrastructure.AbstractIntegrationTest; import de.effigenix.infrastructure.inventory.web.dto.CreateStorageLocationRequest; +import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; import de.effigenix.infrastructure.inventory.web.dto.UpdateStorageLocationRequest; import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; @@ -225,6 +226,60 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== Einzelnen Lagerort abfragen ==================== + + @Test + @DisplayName("Lagerort per ID abfragen → 200") + void getStorageLocation_existing_returns200() throws Exception { + String id = createAndReturnId("Detail Test", "COLD_ROOM", "-2", "8"); + + mockMvc.perform(get("/api/inventory/storage-locations/" + id) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.name").value("Detail Test")) + .andExpect(jsonPath("$.storageType").value("COLD_ROOM")) + .andExpect(jsonPath("$.temperatureRange.minTemperature").value(-2)) + .andExpect(jsonPath("$.temperatureRange.maxTemperature").value(8)) + .andExpect(jsonPath("$.active").value(true)); + } + + @Test + @DisplayName("Nicht existierenden Lagerort per ID abfragen → 404") + void getStorageLocation_notFound_returns404() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NOT_FOUND")); + } + + @Test + @DisplayName("Lagerort per ID abfragen mit STOCK_READ → 200") + void getStorageLocation_withReaderToken_returns200() throws Exception { + String id = createAndReturnId("Reader Detail", "DRY_STORAGE", null, null); + + mockMvc.perform(get("/api/inventory/storage-locations/" + id) + .header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.name").value("Reader Detail")); + } + + @Test + @DisplayName("Lagerort per ID abfragen ohne passende Berechtigung → 403") + void getStorageLocation_withViewerToken_returns403() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Lagerort per ID abfragen ohne Token → 401") + void getStorageLocation_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/inventory/storage-locations/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + // ==================== Lagerort bearbeiten (Story 1.2) ==================== @Test @@ -346,6 +401,26 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.active").value(false)); } + @Test + @DisplayName("Lagerort mit Bestand deaktivieren → 409") + void deactivateStorageLocation_withStock_returns409() throws Exception { + String id = createAndReturnId("Stock Location", "DRY_STORAGE", null, null); + + // Stock an diesem Lagerort anlegen + var stockRequest = new CreateStockRequest( + UUID.randomUUID().toString(), id, null, null, null); + mockMvc.perform(post("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(stockRequest))) + .andExpect(status().isCreated()); + + mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/deactivate") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("STOCK_EXISTS_AT_LOCATION")); + } + @Test @DisplayName("Bereits inaktiven Lagerort deaktivieren → 409") void deactivateStorageLocation_alreadyInactive_returns409() throws Exception {