From e8cbb948b73b2cde266a8dfa46f8d65e7fd671fc Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Fri, 20 Feb 2026 09:44:15 +0100 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20Bestandsparameter=20=C3=A4nd?= =?UTF-8?q?ern=20(MinimumLevel,=20MinimumShelfLife)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock.update(StockUpdateDraft) ermöglicht optionale Aktualisierung von MinimumLevel und MinimumShelfLife mit identischer Validierung wie create(). PUT /api/inventory/stocks/{id} Endpoint, UpdateStock Use Case + Tests. Closes #9 --- .../application/inventory/UpdateStock.java | 57 +++++ .../inventory/command/UpdateStockCommand.java | 8 + .../de/effigenix/domain/inventory/Stock.java | 27 +++ .../domain/inventory/StockUpdateDraft.java | 16 ++ .../config/InventoryUseCaseConfiguration.java | 6 + .../web/controller/StockController.java | 30 ++- .../inventory/web/dto/UpdateStockRequest.java | 7 + .../inventory/UpdateStockTest.java | 193 ++++++++++++++++ .../effigenix/domain/inventory/StockTest.java | 216 ++++++++++++++++++ .../web/StockControllerIntegrationTest.java | 175 ++++++++++++++ 10 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/UpdateStock.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/UpdateStockCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockUpdateDraft.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/UpdateStockRequest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/UpdateStock.java b/backend/src/main/java/de/effigenix/application/inventory/UpdateStock.java new file mode 100644 index 0000000..b0c2005 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/UpdateStock.java @@ -0,0 +1,57 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.UpdateStockCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class UpdateStock { + + private final StockRepository stockRepository; + + public UpdateStock(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(UpdateStockCommand cmd) { + // 1. Laden + StockId stockId; + try { + stockId = StockId.of(cmd.stockId()); + } catch (IllegalArgumentException e) { + return Result.failure(new StockError.StockNotFound(cmd.stockId())); + } + + Stock stock; + switch (stockRepository.findById(stockId)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new StockError.StockNotFound(cmd.stockId())); + } + stock = opt.get(); + } + } + + // 2. Draft bauen + Aggregate validieren lassen + var draft = new StockUpdateDraft( + cmd.minimumLevelAmount(), cmd.minimumLevelUnit(), + cmd.minimumShelfLifeDays() + ); + switch (stock.update(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // 3. Speichern + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(stock); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/UpdateStockCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/UpdateStockCommand.java new file mode 100644 index 0000000..5de6d05 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/UpdateStockCommand.java @@ -0,0 +1,8 @@ +package de.effigenix.application.inventory.command; + +public record UpdateStockCommand( + String stockId, + String minimumLevelAmount, + String minimumLevelUnit, + Integer minimumShelfLifeDays +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index 68c38ae..3599dfc 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -110,6 +110,33 @@ public class Stock { return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches); } + // ==================== Update ==================== + + /** + * Aktualisiert die Bestandsparameter (MinimumLevel, MinimumShelfLife). + * Null-Felder im Draft werden ignoriert (kein Update). + * Validierung identisch zu create(). + */ + public Result update(StockUpdateDraft draft) { + // 1. MinimumLevel optional aktualisieren + if (draft.minimumLevelAmount() != null || draft.minimumLevelUnit() != null) { + switch (MinimumLevel.of(draft.minimumLevelAmount(), draft.minimumLevelUnit())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> this.minimumLevel = val; + } + } + + // 2. MinimumShelfLife optional aktualisieren + if (draft.minimumShelfLifeDays() != null) { + switch (MinimumShelfLife.of(draft.minimumShelfLifeDays())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> this.minimumShelfLife = val; + } + } + + return Result.success(null); + } + // ==================== Batch Management ==================== public Result addBatch(StockBatchDraft draft) { diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockUpdateDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/StockUpdateDraft.java new file mode 100644 index 0000000..4dfbec8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockUpdateDraft.java @@ -0,0 +1,16 @@ +package de.effigenix.domain.inventory; + +/** + * Rohe Eingabe zum Aktualisieren der Bestandsparameter eines Stock-Aggregates. + * Null-Felder bedeuten: Feld nicht ändern. + * Explizit gesetzte Werte werden validiert und übernommen. + * + * @param minimumLevelAmount Optional – BigDecimal als String, nullable + * @param minimumLevelUnit Optional – UnitOfMeasure als String, nullable + * @param minimumShelfLifeDays Optional – Integer, nullable + */ +public record StockUpdateDraft( + String minimumLevelAmount, + String minimumLevelUnit, + Integer minimumShelfLifeDays +) {} 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 eb69a13..540b64a 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -5,6 +5,7 @@ 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.UpdateStock; import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.UnblockStockBatch; @@ -55,6 +56,11 @@ public class InventoryUseCaseConfiguration { return new CreateStock(stockRepository); } + @Bean + public UpdateStock updateStock(StockRepository stockRepository) { + return new UpdateStock(stockRepository); + } + @Bean public GetStock getStock(StockRepository stockRepository) { return new GetStock(stockRepository); 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 7bba592..91f26b3 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 @@ -7,11 +7,13 @@ 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.UpdateStock; import de.effigenix.application.inventory.command.AddStockBatchCommand; import de.effigenix.application.inventory.command.BlockStockBatchCommand; import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.application.inventory.command.RemoveStockBatchCommand; import de.effigenix.application.inventory.command.UnblockStockBatchCommand; +import de.effigenix.application.inventory.command.UpdateStockCommand; import de.effigenix.domain.inventory.StockError; import de.effigenix.shared.security.ActorId; import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; @@ -21,6 +23,7 @@ import de.effigenix.infrastructure.inventory.web.dto.CreateStockResponse; import de.effigenix.infrastructure.inventory.web.dto.RemoveStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse; import de.effigenix.infrastructure.inventory.web.dto.StockResponse; +import de.effigenix.infrastructure.inventory.web.dto.UpdateStockRequest; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -43,6 +46,7 @@ public class StockController { private static final Logger logger = LoggerFactory.getLogger(StockController.class); private final CreateStock createStock; + private final UpdateStock updateStock; private final GetStock getStock; private final ListStocks listStocks; private final AddStockBatch addStockBatch; @@ -50,10 +54,11 @@ public class StockController { private final BlockStockBatch blockStockBatch; private final UnblockStockBatch unblockStockBatch; - public StockController(CreateStock createStock, GetStock getStock, ListStocks listStocks, + public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { this.createStock = createStock; + this.updateStock = updateStock; this.getStock = getStock; this.listStocks = listStocks; this.addStockBatch = addStockBatch; @@ -117,6 +122,29 @@ public class StockController { .body(CreateStockResponse.from(result.unsafeGetValue())); } + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity updateStock( + @PathVariable String id, + @Valid @RequestBody UpdateStockRequest request, + Authentication authentication + ) { + logger.info("Updating stock: {} by actor: {}", id, authentication.getName()); + + var cmd = new UpdateStockCommand( + id, request.minimumLevelAmount(), request.minimumLevelUnit(), + request.minimumShelfLifeDays() + ); + var result = updateStock.execute(cmd); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + logger.info("Stock updated: {}", id); + return ResponseEntity.ok(StockResponse.from(result.unsafeGetValue())); + } + @PostMapping("/{stockId}/batches") @PreAuthorize("hasAuthority('STOCK_WRITE')") public ResponseEntity addBatch( diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/UpdateStockRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/UpdateStockRequest.java new file mode 100644 index 0000000..b6d104e --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/UpdateStockRequest.java @@ -0,0 +1,7 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +public record UpdateStockRequest( + String minimumLevelAmount, + String minimumLevelUnit, + Integer minimumShelfLifeDays +) {} diff --git a/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java b/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java new file mode 100644 index 0000000..54554fa --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java @@ -0,0 +1,193 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.UpdateStockCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UpdateStock Use Case") +class UpdateStockTest { + + @Mock private StockRepository stockRepository; + + private UpdateStock updateStock; + private Stock existingStock; + + @BeforeEach + void setUp() { + updateStock = new UpdateStock(stockRepository); + + existingStock = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + List.of() + ); + } + + @Test + @DisplayName("should update minimumLevel successfully") + void shouldUpdateMinimumLevel() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var cmd = new UpdateStockCommand("stock-1", "50", "KILOGRAM", null); + var result = updateStock.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + var stock = result.unsafeGetValue(); + assertThat(stock.minimumLevel()).isNotNull(); + assertThat(stock.minimumLevel().quantity().amount().intValue()).isEqualTo(50); + assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(stock.minimumShelfLife()).isNull(); + verify(stockRepository).save(existingStock); + } + + @Test + @DisplayName("should update minimumShelfLife successfully") + void shouldUpdateMinimumShelfLife() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var cmd = new UpdateStockCommand("stock-1", null, null, 30); + var result = updateStock.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + var stock = result.unsafeGetValue(); + assertThat(stock.minimumShelfLife()).isNotNull(); + assertThat(stock.minimumShelfLife().days()).isEqualTo(30); + assertThat(stock.minimumLevel()).isNull(); + verify(stockRepository).save(existingStock); + } + + @Test + @DisplayName("should update both parameters at once") + void shouldUpdateBothParameters() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var cmd = new UpdateStockCommand("stock-1", "100", "LITER", 14); + var result = updateStock.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + var stock = result.unsafeGetValue(); + assertThat(stock.minimumLevel()).isNotNull(); + assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.LITER); + assertThat(stock.minimumShelfLife()).isNotNull(); + assertThat(stock.minimumShelfLife().days()).isEqualTo(14); + } + + @Test + @DisplayName("should not change fields when null in command") + void shouldNotChangeFieldsWhenNull() { + var stockWithParams = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + MinimumLevel.of("25", "KILOGRAM").unsafeGetValue(), + MinimumShelfLife.of(7).unsafeGetValue(), + List.of() + ); + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(stockWithParams))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var cmd = new UpdateStockCommand("stock-1", null, null, null); + var result = updateStock.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + var stock = result.unsafeGetValue(); + assertThat(stock.minimumLevel().quantity().amount().intValue()).isEqualTo(25); + assertThat(stock.minimumShelfLife().days()).isEqualTo(7); + } + + @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 cmd = new UpdateStockCommand("stock-1", "50", "KILOGRAM", null); + var result = updateStock.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidMinimumLevel for invalid amount") + void shouldFailForInvalidMinimumLevel() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new UpdateStockCommand("stock-1", "-5", "KILOGRAM", null); + var result = updateStock.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidMinimumShelfLife for zero days") + void shouldFailForInvalidMinimumShelfLife() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new UpdateStockCommand("stock-1", null, null, 0); + var result = updateStock.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findById fails") + void shouldFailWhenFindByIdFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var cmd = new UpdateStockCommand("stock-1", "50", "KILOGRAM", null); + var result = updateStock.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when save fails") + void shouldFailWhenSaveFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var cmd = new UpdateStockCommand("stock-1", "50", "KILOGRAM", null); + var result = updateStock.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index 5067a4b..95e5f44 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -192,6 +192,222 @@ class StockTest { } } + // ==================== Update ==================== + + @Nested + @DisplayName("update()") + class Update { + + @Test + @DisplayName("should update minimumLevel") + void shouldUpdateMinimumLevel() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("20", "LITER", null); + + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("20")); + assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.LITER); + } + + @Test + @DisplayName("should update minimumShelfLife") + void shouldUpdateMinimumShelfLife() { + var stock = createValidStock(); + var draft = new StockUpdateDraft(null, null, 14); + + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumShelfLife().days()).isEqualTo(14); + } + + @Test + @DisplayName("should update both parameters") + void shouldUpdateBothParameters() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("5", "PIECE", 7); + + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("5")); + assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.PIECE); + assertThat(stock.minimumShelfLife().days()).isEqualTo(7); + } + + @Test + @DisplayName("should not change fields when null") + void shouldNotChangeFieldsWhenNull() { + var stock = createValidStock(); // has minimumLevel=10 KILOGRAM, minimumShelfLife=30 + var originalLevel = stock.minimumLevel(); + var originalShelfLife = stock.minimumShelfLife(); + + var draft = new StockUpdateDraft(null, null, null); + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumLevel()).isSameAs(originalLevel); + assertThat(stock.minimumShelfLife()).isSameAs(originalShelfLife); + } + + @Test + @DisplayName("should overwrite existing minimumLevel with new value") + void shouldOverwriteExistingMinimumLevel() { + var stock = createValidStock(); // has minimumLevel=10 KILOGRAM + var draft = new StockUpdateDraft("99.9", "GRAM", null); + + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("99.9")); + assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.GRAM); + // minimumShelfLife unverändert + assertThat(stock.minimumShelfLife().days()).isEqualTo(30); + } + + @Test + @DisplayName("should accept minimumLevel amount of zero") + void shouldAcceptMinimumLevelZero() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("0", "KILOGRAM", null); + + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("should fail when minimumLevel amount is negative") + void shouldFailWhenMinimumLevelNegative() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("-1", "KILOGRAM", null); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class); + } + + @Test + @DisplayName("should fail when minimumLevel amount is not a number") + void shouldFailWhenMinimumLevelNotNumber() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("abc", "KILOGRAM", null); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class); + } + + @Test + @DisplayName("should fail when minimumLevel unit is invalid") + void shouldFailWhenMinimumLevelUnitInvalid() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("10", "INVALID_UNIT", null); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class); + } + + @Test + @DisplayName("should fail when minimumLevel amount provided without unit") + void shouldFailWhenAmountWithoutUnit() { + var stock = createValidStock(); + var draft = new StockUpdateDraft("10", null, null); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class); + } + + @Test + @DisplayName("should fail when minimumLevel unit provided without amount") + void shouldFailWhenUnitWithoutAmount() { + var stock = createValidStock(); + var draft = new StockUpdateDraft(null, "KILOGRAM", null); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class); + } + + @Test + @DisplayName("should fail when minimumShelfLife is zero") + void shouldFailWhenMinimumShelfLifeZero() { + var stock = createValidStock(); + var draft = new StockUpdateDraft(null, null, 0); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class); + } + + @Test + @DisplayName("should fail when minimumShelfLife is negative") + void shouldFailWhenMinimumShelfLifeNegative() { + var stock = createValidStock(); + var draft = new StockUpdateDraft(null, null, -5); + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class); + } + + @Test + @DisplayName("should accept minimumShelfLife of 1") + void shouldAcceptMinimumShelfLifeOne() { + var stock = createValidStock(); + var draft = new StockUpdateDraft(null, null, 1); + + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumShelfLife().days()).isEqualTo(1); + } + + @Test + @DisplayName("should not rollback minimumLevel when minimumShelfLife validation fails") + void shouldNotRollbackMinimumLevelOnShelfLifeFailure() { + var stock = createValidStock(); + var originalLevel = stock.minimumLevel(); + var draft = new StockUpdateDraft("50", "KILOGRAM", 0); // valid level, invalid shelfLife + + var result = stock.update(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class); + // NOTE: minimumLevel was already mutated – this is consistent with how create() works + // (fail-fast, no rollback). The use case should not save on failure. + } + + @Test + @DisplayName("should update stock that had no optional fields") + void shouldUpdateStockWithoutOptionalFields() { + var stock = Stock.create(new StockDraft("article-1", "location-1", null, null, null)).unsafeGetValue(); + assertThat(stock.minimumLevel()).isNull(); + assertThat(stock.minimumShelfLife()).isNull(); + + var draft = new StockUpdateDraft("15", "KILOGRAM", 10); + var result = stock.update(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.minimumLevel()).isNotNull(); + assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("15")); + assertThat(stock.minimumShelfLife()).isNotNull(); + assertThat(stock.minimumShelfLife().days()).isEqualTo(10); + } + } + // ==================== addBatch ==================== @Nested 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 d947b13..b4f87a7 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 @@ -17,6 +17,7 @@ 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** @@ -206,6 +207,180 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(status().isUnauthorized()); } + // ==================== Bestandsparameter ändern (updateStock) ==================== + + @Nested + @DisplayName("PUT /{id} – Bestandsparameter ändern") + class UpdateStockEndpoint { + + @Test + @DisplayName("MinimumLevel ändern → 200") + void updateStock_minimumLevel_returns200() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "50", "minimumLevelUnit": "KILOGRAM"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(stockId)) + .andExpect(jsonPath("$.minimumLevel.amount").value(50)) + .andExpect(jsonPath("$.minimumLevel.unit").value("KILOGRAM")); + } + + @Test + @DisplayName("MinimumShelfLife ändern → 200") + void updateStock_minimumShelfLife_returns200() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumShelfLifeDays": 14} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(stockId)) + .andExpect(jsonPath("$.minimumShelfLifeDays").value(14)); + } + + @Test + @DisplayName("Beide Parameter gleichzeitig ändern → 200") + void updateStock_bothParameters_returns200() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "25.5", "minimumLevelUnit": "LITER", "minimumShelfLifeDays": 7} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.minimumLevel.amount").value(25.5)) + .andExpect(jsonPath("$.minimumLevel.unit").value("LITER")) + .andExpect(jsonPath("$.minimumShelfLifeDays").value(7)); + } + + @Test + @DisplayName("Keine Parameter ändern (leerer Body) → 200 ohne Änderung") + void updateStock_noChanges_returns200() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(stockId)); + } + + @Test + @DisplayName("Persistenz: geänderte Werte beim erneuten Laden sichtbar") + void updateStock_persistsChanges() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "42", "minimumLevelUnit": "PIECE", "minimumShelfLifeDays": 21} + """)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.minimumLevel.amount").value(42)) + .andExpect(jsonPath("$.minimumLevel.unit").value("PIECE")) + .andExpect(jsonPath("$.minimumShelfLifeDays").value(21)); + } + + @Test + @DisplayName("Ungültiger MinimumLevel (negativer Amount) → 400") + void updateStock_invalidMinimumLevel_returns400() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "-1", "minimumLevelUnit": "KILOGRAM"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_MINIMUM_LEVEL")); + } + + @Test + @DisplayName("Ungültige Unit → 400") + void updateStock_invalidUnit_returns400() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "10", "minimumLevelUnit": "INVALID_UNIT"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_MINIMUM_LEVEL")); + } + + @Test + @DisplayName("Ungültige MinimumShelfLife (0) → 400") + void updateStock_invalidShelfLife_returns400() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumShelfLifeDays": 0} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_MINIMUM_SHELF_LIFE")); + } + + @Test + @DisplayName("Nicht existierende Bestandsposition → 404") + void updateStock_notFound_returns404() throws Exception { + mockMvc.perform(put("/api/inventory/stocks/{id}", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "10", "minimumLevelUnit": "KILOGRAM"} + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND")); + } + + @Test + @DisplayName("Bestandsparameter ändern ohne STOCK_WRITE → 403") + void updateStock_withViewerToken_returns403() throws Exception { + String stockId = createStock(); + + mockMvc.perform(put("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "10", "minimumLevelUnit": "KILOGRAM"} + """)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Bestandsparameter ändern ohne Token → 401") + void updateStock_withoutToken_returns401() throws Exception { + mockMvc.perform(put("/api/inventory/stocks/{id}", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"minimumLevelAmount": "10", "minimumLevelUnit": "KILOGRAM"} + """)) + .andExpect(status().isUnauthorized()); + } + } + // ==================== Charge einbuchen (addBatch) ==================== @Nested