1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 12:29:36 +01:00

feat(inventory): Bestandsparameter ändern (MinimumLevel, MinimumShelfLife)

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
This commit is contained in:
Sebastian Frick 2026-02-20 09:44:15 +01:00
parent 1c65ac7795
commit e8cbb948b7
10 changed files with 734 additions and 1 deletions

View file

@ -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<StockError, Stock> 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);
}
}

View file

@ -0,0 +1,8 @@
package de.effigenix.application.inventory.command;
public record UpdateStockCommand(
String stockId,
String minimumLevelAmount,
String minimumLevelUnit,
Integer minimumShelfLifeDays
) {}

View file

@ -110,6 +110,33 @@ public class Stock {
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches); 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<StockError, Void> 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 ==================== // ==================== Batch Management ====================
public Result<StockError, StockBatch> addBatch(StockBatchDraft draft) { public Result<StockError, StockBatch> addBatch(StockBatchDraft draft) {

View file

@ -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
) {}

View file

@ -5,6 +5,7 @@ import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
@ -55,6 +56,11 @@ public class InventoryUseCaseConfiguration {
return new CreateStock(stockRepository); return new CreateStock(stockRepository);
} }
@Bean
public UpdateStock updateStock(StockRepository stockRepository) {
return new UpdateStock(stockRepository);
}
@Bean @Bean
public GetStock getStock(StockRepository stockRepository) { public GetStock getStock(StockRepository stockRepository) {
return new GetStock(stockRepository); return new GetStock(stockRepository);

View file

@ -7,11 +7,13 @@ import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch; 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.AddStockBatchCommand;
import de.effigenix.application.inventory.command.BlockStockBatchCommand; import de.effigenix.application.inventory.command.BlockStockBatchCommand;
import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.application.inventory.command.CreateStockCommand;
import de.effigenix.application.inventory.command.RemoveStockBatchCommand; import de.effigenix.application.inventory.command.RemoveStockBatchCommand;
import de.effigenix.application.inventory.command.UnblockStockBatchCommand; import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
import de.effigenix.application.inventory.command.UpdateStockCommand;
import de.effigenix.domain.inventory.StockError; import de.effigenix.domain.inventory.StockError;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; 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.RemoveStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse; import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse;
import de.effigenix.infrastructure.inventory.web.dto.StockResponse; 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.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -43,6 +46,7 @@ public class StockController {
private static final Logger logger = LoggerFactory.getLogger(StockController.class); private static final Logger logger = LoggerFactory.getLogger(StockController.class);
private final CreateStock createStock; private final CreateStock createStock;
private final UpdateStock updateStock;
private final GetStock getStock; private final GetStock getStock;
private final ListStocks listStocks; private final ListStocks listStocks;
private final AddStockBatch addStockBatch; private final AddStockBatch addStockBatch;
@ -50,10 +54,11 @@ public class StockController {
private final BlockStockBatch blockStockBatch; private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch; 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, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) {
this.createStock = createStock; this.createStock = createStock;
this.updateStock = updateStock;
this.getStock = getStock; this.getStock = getStock;
this.listStocks = listStocks; this.listStocks = listStocks;
this.addStockBatch = addStockBatch; this.addStockBatch = addStockBatch;
@ -117,6 +122,29 @@ public class StockController {
.body(CreateStockResponse.from(result.unsafeGetValue())); .body(CreateStockResponse.from(result.unsafeGetValue()));
} }
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StockResponse> 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") @PostMapping("/{stockId}/batches")
@PreAuthorize("hasAuthority('STOCK_WRITE')") @PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StockBatchResponse> addBatch( public ResponseEntity<StockBatchResponse> addBatch(

View file

@ -0,0 +1,7 @@
package de.effigenix.infrastructure.inventory.web.dto;
public record UpdateStockRequest(
String minimumLevelAmount,
String minimumLevelUnit,
Integer minimumShelfLifeDays
) {}

View file

@ -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);
}
}

View file

@ -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 ==================== // ==================== addBatch ====================
@Nested @Nested

View file

@ -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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** /**
@ -206,6 +207,180 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(status().isUnauthorized()); .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) ==================== // ==================== Charge einbuchen (addBatch) ====================
@Nested @Nested