From 1ef37497c34c0883e9072172315f146ab9b1ad04 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Fri, 20 Feb 2026 08:45:20 +0100 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20GET-Endpoints=20f=C3=BCr=20B?= =?UTF-8?q?estandsposition=20und=20Chargen=20abfragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query-Endpoints für Stock-Aggregate: GET /api/inventory/stocks (mit optionalen Filtern storageLocationId/articleId) und GET /api/inventory/stocks/{id}. StockResponse enthält nun Batches, totalQuantity, availableQuantity und quantityUnit. Abgesichert über @PreAuthorize STOCK_READ. --- .../application/inventory/GetStock.java | 31 +++ .../application/inventory/ListStocks.java | 58 +++++ .../domain/inventory/StockRepository.java | 7 + .../config/InventoryUseCaseConfiguration.java | 12 + .../repository/JpaStockRepository.java | 40 ++++ .../repository/StockJpaRepository.java | 5 + .../web/controller/StockController.java | 41 +++- .../inventory/web/dto/StockResponse.java | 33 ++- .../stub/StubStockRepository.java | 16 ++ .../application/inventory/GetStockTest.java | 94 ++++++++ .../application/inventory/ListStocksTest.java | 145 ++++++++++++ .../web/StockControllerIntegrationTest.java | 207 ++++++++++++++++++ 12 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/GetStock.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/ListStocks.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/GetStock.java b/backend/src/main/java/de/effigenix/application/inventory/GetStock.java new file mode 100644 index 0000000..d277f7e --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/GetStock.java @@ -0,0 +1,31 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.Stock; +import de.effigenix.domain.inventory.StockError; +import de.effigenix.domain.inventory.StockId; +import de.effigenix.domain.inventory.StockRepository; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +public class GetStock { + + private final StockRepository stockRepository; + + public GetStock(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(String stockId) { + if (stockId == null || stockId.isBlank()) { + return Result.failure(new StockError.StockNotFound(stockId)); + } + + return switch (stockRepository.findById(StockId.of(stockId))) { + case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message())); + case Result.Success(var opt) -> opt + .>map(Result::success) + .orElseGet(() -> Result.failure(new StockError.StockNotFound(stockId))); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListStocks.java b/backend/src/main/java/de/effigenix/application/inventory/ListStocks.java new file mode 100644 index 0000000..749764a --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ListStocks.java @@ -0,0 +1,58 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.Stock; +import de.effigenix.domain.inventory.StockError; +import de.effigenix.domain.inventory.StockRepository; +import de.effigenix.domain.inventory.StorageLocationId; +import de.effigenix.domain.masterdata.ArticleId; +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 ListStocks { + + private final StockRepository stockRepository; + + public ListStocks(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result> execute(String storageLocationId, String articleId) { + if (storageLocationId != null && articleId != null) { + return Result.failure(new StockError.InvalidArticleId( + "Only one filter parameter allowed: storageLocationId or articleId")); + } + + if (storageLocationId != null) { + StorageLocationId locId; + try { + locId = StorageLocationId.of(storageLocationId); + } catch (IllegalArgumentException e) { + return Result.failure(new StockError.InvalidStorageLocationId(e.getMessage())); + } + return mapResult(stockRepository.findAllByStorageLocationId(locId)); + } + + if (articleId != null) { + ArticleId artId; + try { + artId = ArticleId.of(articleId); + } catch (IllegalArgumentException e) { + return Result.failure(new StockError.InvalidArticleId(e.getMessage())); + } + return mapResult(stockRepository.findAllByArticleId(artId)); + } + + return mapResult(stockRepository.findAll()); + } + + private Result> mapResult(Result> result) { + return switch (result) { + case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message())); + case Result.Success(var stocks) -> Result.success(stocks); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java index 7d848b0..b025643 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java @@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import java.util.List; import java.util.Optional; public interface StockRepository { @@ -14,5 +15,11 @@ public interface StockRepository { Result existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId); + Result> findAll(); + + Result> findAllByStorageLocationId(StorageLocationId storageLocationId); + + Result> findAllByArticleId(ArticleId articleId); + Result save(Stock stock); } 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 bb726a5..eb69a13 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -4,6 +4,8 @@ import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.CreateStock; +import de.effigenix.application.inventory.GetStock; +import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.CreateStorageLocation; @@ -53,6 +55,16 @@ public class InventoryUseCaseConfiguration { return new CreateStock(stockRepository); } + @Bean + public GetStock getStock(StockRepository stockRepository) { + return new GetStock(stockRepository); + } + + @Bean + public ListStocks listStocks(StockRepository stockRepository) { + return new ListStocks(stockRepository); + } + @Bean public AddStockBatch addStockBatch(StockRepository stockRepository) { return new AddStockBatch(stockRepository); diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java index 9d82a98..ae14e61 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java @@ -12,6 +12,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Repository @@ -63,6 +64,45 @@ public class JpaStockRepository implements StockRepository { } } + @Override + public Result> findAll() { + try { + List result = jpaRepository.findAll().stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAll", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAllByStorageLocationId(StorageLocationId storageLocationId) { + try { + List result = jpaRepository.findAllByStorageLocationId(storageLocationId.value()).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAllByStorageLocationId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAllByArticleId(ArticleId articleId) { + try { + List result = jpaRepository.findAllByArticleId(articleId.value()).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAllByArticleId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override @Transactional public Result save(Stock stock) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java index 287bab5..a254961 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.persistence.repository; import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface StockJpaRepository extends JpaRepository { @@ -10,4 +11,8 @@ public interface StockJpaRepository extends JpaRepository { Optional findByArticleIdAndStorageLocationId(String articleId, String storageLocationId); boolean existsByArticleIdAndStorageLocationId(String articleId, String storageLocationId); + + List findAllByStorageLocationId(String storageLocationId); + + List findAllByArticleId(String articleId); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java index fc19447..981f2e0 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java @@ -3,6 +3,8 @@ package de.effigenix.infrastructure.inventory.web.controller; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.CreateStock; +import de.effigenix.application.inventory.GetStock; +import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.command.AddStockBatchCommand; @@ -29,6 +31,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/stocks") @SecurityRequirement(name = "Bearer Authentication") @@ -38,20 +42,55 @@ public class StockController { private static final Logger logger = LoggerFactory.getLogger(StockController.class); private final CreateStock createStock; + private final GetStock getStock; + private final ListStocks listStocks; private final AddStockBatch addStockBatch; private final RemoveStockBatch removeStockBatch; private final BlockStockBatch blockStockBatch; private final UnblockStockBatch unblockStockBatch; - public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, + public StockController(CreateStock createStock, GetStock getStock, ListStocks listStocks, + AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { this.createStock = createStock; + this.getStock = getStock; + this.listStocks = listStocks; this.addStockBatch = addStockBatch; this.removeStockBatch = removeStockBatch; this.blockStockBatch = blockStockBatch; this.unblockStockBatch = unblockStockBatch; } + @GetMapping + @PreAuthorize("hasAuthority('STOCK_READ')") + public ResponseEntity> listStocks( + @RequestParam(required = false) String storageLocationId, + @RequestParam(required = false) String articleId + ) { + var result = listStocks.execute(storageLocationId, articleId); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + List responses = result.unsafeGetValue().stream() + .map(StockResponse::from) + .toList(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('STOCK_READ')") + public ResponseEntity getStock(@PathVariable String id) { + var result = getStock.execute(id); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(StockResponse.from(result.unsafeGetValue())); + } + @PostMapping @PreAuthorize("hasAuthority('STOCK_WRITE')") public ResponseEntity createStock( diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java index 485d570..a7c0d10 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java @@ -1,17 +1,23 @@ package de.effigenix.infrastructure.inventory.web.dto; import de.effigenix.domain.inventory.Stock; +import de.effigenix.domain.inventory.StockBatchStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; +import java.util.List; -@Schema(requiredProperties = {"id", "articleId", "storageLocationId"}) +@Schema(requiredProperties = {"id", "articleId", "storageLocationId", "batches", "totalQuantity", "availableQuantity"}) public record StockResponse( String id, String articleId, String storageLocationId, @Schema(nullable = true) MinimumLevelResponse minimumLevel, - @Schema(nullable = true) Integer minimumShelfLifeDays + @Schema(nullable = true) Integer minimumShelfLifeDays, + List batches, + BigDecimal totalQuantity, + @Schema(nullable = true) String quantityUnit, + BigDecimal availableQuantity ) { public static StockResponse from(Stock stock) { @@ -28,12 +34,33 @@ public record StockResponse( shelfLifeDays = stock.minimumShelfLife().days(); } + List batchResponses = stock.batches().stream() + .map(StockBatchResponse::from) + .toList(); + + BigDecimal totalQuantity = stock.batches().stream() + .map(b -> b.quantity().amount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal availableQuantity = stock.batches().stream() + .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) + .map(b -> b.quantity().amount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + String quantityUnit = stock.batches().isEmpty() + ? null + : stock.batches().getFirst().quantity().uom().name(); + return new StockResponse( stock.id().value(), stock.articleId().value(), stock.storageLocationId().value(), minimumLevel, - shelfLifeDays + shelfLifeDays, + batchResponses, + totalQuantity, + quantityUnit, + availableQuantity ); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java index bacccf8..3e2b527 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java @@ -10,6 +10,7 @@ import de.effigenix.shared.common.Result; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -34,6 +35,21 @@ public class StubStockRepository implements StockRepository { return Result.failure(STUB_ERROR); } + @Override + public Result> findAll() { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findAllByStorageLocationId(StorageLocationId storageLocationId) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findAllByArticleId(ArticleId articleId) { + return Result.failure(STUB_ERROR); + } + @Override public Result save(Stock stock) { return Result.failure(STUB_ERROR); diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java new file mode 100644 index 0000000..bb98d25 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java @@ -0,0 +1,94 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +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.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetStock Use Case") +class GetStockTest { + + @Mock private StockRepository stockRepository; + + private GetStock getStock; + private Stock existingStock; + + @BeforeEach + void setUp() { + getStock = new GetStock(stockRepository); + + existingStock = Stock.reconstitute( + StockId.of("stock-1"), + ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, List.of() + ); + } + + @Test + @DisplayName("should return stock when found") + void shouldReturnStockWhenFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var result = getStock.execute("stock-1"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().id().value()).isEqualTo("stock-1"); + } + + @Test + @DisplayName("should fail with StockNotFound when stock does not exist") + void shouldFailWhenStockNotFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = getStock.execute("stock-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails") + void shouldFailWhenRepositoryFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = getStock.execute("stock-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with StockNotFound when id is null") + void shouldFailWhenIdIsNull() { + var result = getStock.execute(null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + } + + @Test + @DisplayName("should fail with StockNotFound when id is blank") + void shouldFailWhenIdIsBlank() { + var result = getStock.execute(" "); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java new file mode 100644 index 0000000..3c918b2 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java @@ -0,0 +1,145 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +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.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ListStocks Use Case") +class ListStocksTest { + + @Mock private StockRepository stockRepository; + + private ListStocks listStocks; + private Stock stock1; + private Stock stock2; + + @BeforeEach + void setUp() { + listStocks = new ListStocks(stockRepository); + + stock1 = Stock.reconstitute( + StockId.of("stock-1"), + ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, List.of() + ); + + stock2 = Stock.reconstitute( + StockId.of("stock-2"), + ArticleId.of("article-2"), + StorageLocationId.of("location-1"), + null, null, List.of() + ); + } + + @Test + @DisplayName("should return all stocks when no filter provided") + void shouldReturnAllStocksWhenNoFilter() { + when(stockRepository.findAll()).thenReturn(Result.success(List.of(stock1, stock2))); + + var result = listStocks.execute(null, null); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(2); + verify(stockRepository).findAll(); + } + + @Test + @DisplayName("should filter by storageLocationId") + void shouldFilterByStorageLocationId() { + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of(stock1, stock2))); + + var result = listStocks.execute("location-1", null); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(2); + verify(stockRepository).findAllByStorageLocationId(StorageLocationId.of("location-1")); + verify(stockRepository, never()).findAll(); + } + + @Test + @DisplayName("should filter by articleId") + void shouldFilterByArticleId() { + when(stockRepository.findAllByArticleId(ArticleId.of("article-1"))) + .thenReturn(Result.success(List.of(stock1))); + + var result = listStocks.execute(null, "article-1"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(stockRepository).findAllByArticleId(ArticleId.of("article-1")); + verify(stockRepository, never()).findAll(); + } + + @Test + @DisplayName("should fail when both filters are provided") + void shouldFailWhenBothFiltersProvided() { + var result = listStocks.execute("location-1", "article-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class); + verifyNoInteractions(stockRepository); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails for findAll") + void shouldFailWhenRepositoryFailsForFindAll() { + when(stockRepository.findAll()) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = listStocks.execute(null, null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails for storageLocationId filter") + void shouldFailWhenRepositoryFailsForStorageLocationFilter() { + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = listStocks.execute("location-1", null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails for articleId filter") + void shouldFailWhenRepositoryFailsForArticleIdFilter() { + when(stockRepository.findAllByArticleId(ArticleId.of("article-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = listStocks.execute(null, "article-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should return empty list when no stocks match") + void shouldReturnEmptyListWhenNoStocksMatch() { + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("unknown"))) + .thenReturn(Result.success(List.of())); + + var result = listStocks.execute("unknown", null); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } +} 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 ca29240..d947b13 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 @@ -15,6 +15,7 @@ import org.springframework.http.MediaType; import java.util.Set; import java.util.UUID; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -24,6 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * Abgedeckte Testfälle: * - Story 2.1 – Bestandsposition anlegen * - Story 2.2 – Charge einbuchen (addBatch) + * - Story 2.5 – Bestandsposition und Chargen abfragen */ @DisplayName("Stock Controller Integration Tests") class StockControllerIntegrationTest extends AbstractIntegrationTest { @@ -548,6 +550,183 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== Bestandsposition abfragen (getStock) ==================== + + @Nested + @DisplayName("GET /{id} – Bestandsposition abfragen") + class GetStockById { + + @Test + @DisplayName("Bestandsposition abfragen → 200 mit Batches und Quantities") + void getStock_returns200WithBatchesAndQuantities() throws Exception { + String stockId = createStock(); + addBatchToStock(stockId); + + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(stockId)) + .andExpect(jsonPath("$.batches").isArray()) + .andExpect(jsonPath("$.batches.length()").value(1)) + .andExpect(jsonPath("$.batches[0].batchId").isNotEmpty()) + .andExpect(jsonPath("$.batches[0].status").value("AVAILABLE")) + .andExpect(jsonPath("$.totalQuantity").value(10)) + .andExpect(jsonPath("$.quantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.availableQuantity").value(10)); + } + + @Test + @DisplayName("Bestandsposition ohne Chargen abfragen → 200 mit leeren Batches") + void getStock_noBatches_returns200() throws Exception { + String stockId = createStock(); + + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(stockId)) + .andExpect(jsonPath("$.batches").isArray()) + .andExpect(jsonPath("$.batches.length()").value(0)) + .andExpect(jsonPath("$.totalQuantity").value(0)) + .andExpect(jsonPath("$.quantityUnit").isEmpty()) + .andExpect(jsonPath("$.availableQuantity").value(0)); + } + + @Test + @DisplayName("Bestandsposition mit gesperrter Charge → availableQuantity exkludiert BLOCKED") + void getStock_withBlockedBatch_availableExcludesBlocked() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + // Charge sperren + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue"} + """)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalQuantity").value(10)) + .andExpect(jsonPath("$.availableQuantity").value(0)); + } + + @Test + @DisplayName("Nicht existierende Bestandsposition → 404") + void getStock_notFound_returns404() throws Exception { + mockMvc.perform(get("/api/inventory/stocks/{id}", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND")); + } + + @Test + @DisplayName("Bestandsposition abfragen ohne STOCK_READ → 403") + void getStock_withViewerToken_returns403() throws Exception { + String stockId = createStock(); + + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Bestandsposition abfragen ohne Token → 401") + void getStock_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/inventory/stocks/{id}", UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()); + } + } + + // ==================== Bestandspositionen auflisten (listStocks) ==================== + + @Nested + @DisplayName("GET / – Bestandspositionen auflisten") + class ListStocksEndpoint { + + @Test + @DisplayName("Alle Bestandspositionen auflisten → 200") + void listStocks_returnsAll() throws Exception { + createStock(); + String storageLocationId2 = createStorageLocation(); + createStockForLocation(storageLocationId2); + + mockMvc.perform(get("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + @DisplayName("Bestandspositionen nach storageLocationId filtern → 200") + void listStocks_filterByStorageLocationId() throws Exception { + createStock(); + String storageLocationId2 = createStorageLocation(); + createStockForLocation(storageLocationId2); + + mockMvc.perform(get("/api/inventory/stocks") + .param("storageLocationId", storageLocationId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].storageLocationId").value(storageLocationId)); + } + + @Test + @DisplayName("Bestandspositionen nach articleId filtern → 200") + void listStocks_filterByArticleId() throws Exception { + String articleId = UUID.randomUUID().toString(); + createStockForArticle(articleId); + + mockMvc.perform(get("/api/inventory/stocks") + .param("articleId", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].articleId").value(articleId)); + } + + @Test + @DisplayName("Leere Liste wenn keine Stocks vorhanden → 200") + void listStocks_empty_returns200() throws Exception { + mockMvc.perform(get("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("Bestandspositionen mit Batches enthalten berechnete Quantities") + void listStocks_withBatches_containsQuantities() throws Exception { + String stockId = createStock(); + addBatchToStock(stockId); + + mockMvc.perform(get("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].batches.length()").value(1)) + .andExpect(jsonPath("$[0].totalQuantity").value(10)) + .andExpect(jsonPath("$[0].availableQuantity").value(10)); + } + + @Test + @DisplayName("Bestandspositionen auflisten ohne STOCK_READ → 403") + void listStocks_withViewerToken_returns403() throws Exception { + mockMvc.perform(get("/api/inventory/stocks") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Bestandspositionen auflisten ohne Token → 401") + void listStocks_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/inventory/stocks")) + .andExpect(status().isUnauthorized()); + } + } + // ==================== Hilfsmethoden ==================== private String createStorageLocation() throws Exception { @@ -593,4 +772,32 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); } + + private String createStockForLocation(String locationId) throws Exception { + var request = new CreateStockRequest( + UUID.randomUUID().toString(), locationId, null, null, null); + + var result = mockMvc.perform(post("/api/inventory/stocks") + .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 createStockForArticle(String articleId) throws Exception { + var request = new CreateStockRequest( + articleId, storageLocationId, null, null, null); + + var result = mockMvc.perform(post("/api/inventory/stocks") + .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(); + } }