mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 12:09:35 +01:00
feat(inventory): GET-Endpoints für Bestandsposition und Chargen abfragen
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.
This commit is contained in:
parent
e0ac2c2f41
commit
1ef37497c3
12 changed files with 685 additions and 4 deletions
|
|
@ -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<StockError, Stock> 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
|
||||
.<Result<StockError, Stock>>map(Result::success)
|
||||
.orElseGet(() -> Result.failure(new StockError.StockNotFound(stockId)));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<StockError, List<Stock>> 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<StockError, List<Stock>> mapResult(Result<RepositoryError, List<Stock>> result) {
|
||||
return switch (result) {
|
||||
case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message()));
|
||||
case Result.Success(var stocks) -> Result.success(stocks);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId);
|
||||
|
||||
Result<RepositoryError, List<Stock>> findAll();
|
||||
|
||||
Result<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId);
|
||||
|
||||
Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId);
|
||||
|
||||
Result<RepositoryError, Void> save(Stock stock);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<RepositoryError, List<Stock>> findAll() {
|
||||
try {
|
||||
List<Stock> 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<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId) {
|
||||
try {
|
||||
List<Stock> 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<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId) {
|
||||
try {
|
||||
List<Stock> 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<RepositoryError, Void> save(Stock stock) {
|
||||
|
|
|
|||
|
|
@ -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<StockEntity, String> {
|
||||
|
|
@ -10,4 +11,8 @@ public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
|
|||
Optional<StockEntity> findByArticleIdAndStorageLocationId(String articleId, String storageLocationId);
|
||||
|
||||
boolean existsByArticleIdAndStorageLocationId(String articleId, String storageLocationId);
|
||||
|
||||
List<StockEntity> findAllByStorageLocationId(String storageLocationId);
|
||||
|
||||
List<StockEntity> findAllByArticleId(String articleId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<StockResponse>> 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<StockResponse> responses = result.unsafeGetValue().stream()
|
||||
.map(StockResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(responses);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('STOCK_READ')")
|
||||
public ResponseEntity<StockResponse> 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<StockResponse> createStock(
|
||||
|
|
|
|||
|
|
@ -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<StockBatchResponse> 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<StockBatchResponse> 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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RepositoryError, List<Stock>> findAll() {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Void> save(Stock stock) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue