mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 12:09:35 +01:00
feat(inventory): Reservierung freigeben (#13)
DELETE /api/inventory/stocks/{stockId}/reservations/{reservationId}
gibt eine bestehende Reservierung frei und stellt die verfügbare
Menge wieder her. Zusätzlich Liquibase-Changeset 025 idempotent
gemacht (ON CONFLICT DO NOTHING).
This commit is contained in:
parent
0b49bb2977
commit
2938628db4
9 changed files with 601 additions and 25 deletions
|
|
@ -0,0 +1,46 @@
|
||||||
|
package de.effigenix.application.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.application.inventory.command.ReleaseReservationCommand;
|
||||||
|
import de.effigenix.domain.inventory.*;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public class ReleaseReservation {
|
||||||
|
|
||||||
|
private final StockRepository stockRepository;
|
||||||
|
|
||||||
|
public ReleaseReservation(StockRepository stockRepository) {
|
||||||
|
this.stockRepository = stockRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<StockError, Void> execute(ReleaseReservationCommand cmd) {
|
||||||
|
// 1. Stock laden
|
||||||
|
Stock stock;
|
||||||
|
switch (stockRepository.findById(StockId.of(cmd.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. Reservation freigeben
|
||||||
|
switch (stock.releaseReservation(ReservationId.of(cmd.reservationId()))) {
|
||||||
|
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(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package de.effigenix.application.inventory.command;
|
||||||
|
|
||||||
|
public record ReleaseReservationCommand(String stockId, String reservationId) {}
|
||||||
|
|
@ -33,6 +33,7 @@ import java.util.stream.Collectors;
|
||||||
* - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations
|
* - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations
|
||||||
* - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
|
* - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
|
||||||
* - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC
|
* - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC
|
||||||
|
* - releaseReservation: removes reservation by ID, implicitly freeing allocated quantities
|
||||||
* - reservations track allocated quantities per batch; no over-reservation possible
|
* - reservations track allocated quantities per batch; no over-reservation possible
|
||||||
*/
|
*/
|
||||||
public class Stock {
|
public class Stock {
|
||||||
|
|
@ -353,6 +354,18 @@ public class Stock {
|
||||||
return Result.success(reservation);
|
return Result.success(reservation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Result<StockError, Void> releaseReservation(ReservationId reservationId) {
|
||||||
|
Reservation reservation = reservations.stream()
|
||||||
|
.filter(r -> r.id().equals(reservationId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (reservation == null) {
|
||||||
|
return Result.failure(new StockError.ReservationNotFound(reservationId.value()));
|
||||||
|
}
|
||||||
|
this.reservations.remove(reservation);
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Expiry Management ====================
|
// ==================== Expiry Management ====================
|
||||||
|
|
||||||
public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {
|
public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import de.effigenix.application.inventory.UpdateStock;
|
||||||
import de.effigenix.application.inventory.ListStocks;
|
import de.effigenix.application.inventory.ListStocks;
|
||||||
import de.effigenix.application.inventory.ListStocksBelowMinimum;
|
import de.effigenix.application.inventory.ListStocksBelowMinimum;
|
||||||
import de.effigenix.application.inventory.RemoveStockBatch;
|
import de.effigenix.application.inventory.RemoveStockBatch;
|
||||||
|
import de.effigenix.application.inventory.ReleaseReservation;
|
||||||
import de.effigenix.application.inventory.ReserveStock;
|
import de.effigenix.application.inventory.ReserveStock;
|
||||||
import de.effigenix.application.inventory.UnblockStockBatch;
|
import de.effigenix.application.inventory.UnblockStockBatch;
|
||||||
import de.effigenix.application.inventory.CreateStorageLocation;
|
import de.effigenix.application.inventory.CreateStorageLocation;
|
||||||
|
|
@ -106,6 +107,11 @@ public class InventoryUseCaseConfiguration {
|
||||||
return new ReserveStock(stockRepository);
|
return new ReserveStock(stockRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ReleaseReservation releaseReservation(StockRepository stockRepository) {
|
||||||
|
return new ReleaseReservation(stockRepository);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
|
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
|
||||||
return new CheckStockExpiry(stockRepository);
|
return new CheckStockExpiry(stockRepository);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import de.effigenix.application.inventory.CreateStock;
|
||||||
import de.effigenix.application.inventory.GetStock;
|
import de.effigenix.application.inventory.GetStock;
|
||||||
import de.effigenix.application.inventory.ListStocks;
|
import de.effigenix.application.inventory.ListStocks;
|
||||||
import de.effigenix.application.inventory.ListStocksBelowMinimum;
|
import de.effigenix.application.inventory.ListStocksBelowMinimum;
|
||||||
|
import de.effigenix.application.inventory.ReleaseReservation;
|
||||||
import de.effigenix.application.inventory.RemoveStockBatch;
|
import de.effigenix.application.inventory.RemoveStockBatch;
|
||||||
import de.effigenix.application.inventory.ReserveStock;
|
import de.effigenix.application.inventory.ReserveStock;
|
||||||
import de.effigenix.application.inventory.UnblockStockBatch;
|
import de.effigenix.application.inventory.UnblockStockBatch;
|
||||||
|
|
@ -14,6 +15,7 @@ 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.ReleaseReservationCommand;
|
||||||
import de.effigenix.application.inventory.command.ReserveStockCommand;
|
import de.effigenix.application.inventory.command.ReserveStockCommand;
|
||||||
import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
|
import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
|
||||||
import de.effigenix.application.inventory.command.UpdateStockCommand;
|
import de.effigenix.application.inventory.command.UpdateStockCommand;
|
||||||
|
|
@ -60,12 +62,13 @@ public class StockController {
|
||||||
private final BlockStockBatch blockStockBatch;
|
private final BlockStockBatch blockStockBatch;
|
||||||
private final UnblockStockBatch unblockStockBatch;
|
private final UnblockStockBatch unblockStockBatch;
|
||||||
private final ReserveStock reserveStock;
|
private final ReserveStock reserveStock;
|
||||||
|
private final ReleaseReservation releaseReservation;
|
||||||
|
|
||||||
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
|
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
|
||||||
ListStocksBelowMinimum listStocksBelowMinimum,
|
ListStocksBelowMinimum listStocksBelowMinimum,
|
||||||
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
|
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
|
||||||
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
|
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
|
||||||
ReserveStock reserveStock) {
|
ReserveStock reserveStock, ReleaseReservation releaseReservation) {
|
||||||
this.createStock = createStock;
|
this.createStock = createStock;
|
||||||
this.updateStock = updateStock;
|
this.updateStock = updateStock;
|
||||||
this.getStock = getStock;
|
this.getStock = getStock;
|
||||||
|
|
@ -76,6 +79,7 @@ public class StockController {
|
||||||
this.blockStockBatch = blockStockBatch;
|
this.blockStockBatch = blockStockBatch;
|
||||||
this.unblockStockBatch = unblockStockBatch;
|
this.unblockStockBatch = unblockStockBatch;
|
||||||
this.reserveStock = reserveStock;
|
this.reserveStock = reserveStock;
|
||||||
|
this.releaseReservation = releaseReservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|
@ -286,6 +290,26 @@ public class StockController {
|
||||||
.body(ReservationResponse.from(result.unsafeGetValue()));
|
.body(ReservationResponse.from(result.unsafeGetValue()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{stockId}/reservations/{reservationId}")
|
||||||
|
@PreAuthorize("hasAuthority('STOCK_WRITE')")
|
||||||
|
public ResponseEntity<Void> releaseReservation(
|
||||||
|
@PathVariable String stockId,
|
||||||
|
@PathVariable String reservationId,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
logger.info("Releasing reservation {} of stock {} by actor: {}", reservationId, stockId, authentication.getName());
|
||||||
|
|
||||||
|
var cmd = new ReleaseReservationCommand(stockId, reservationId);
|
||||||
|
var result = releaseReservation.execute(cmd);
|
||||||
|
|
||||||
|
if (result.isFailure()) {
|
||||||
|
throw new StockDomainErrorException(result.unsafeGetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Reservation {} of stock {} released", reservationId, stockId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
public static class StockDomainErrorException extends RuntimeException {
|
public static class StockDomainErrorException extends RuntimeException {
|
||||||
private final StockError error;
|
private final StockError error;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,31 +6,16 @@
|
||||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
<changeSet id="025-seed-production-order-permissions" author="effigenix">
|
<changeSet id="025-seed-production-order-permissions" author="effigenix">
|
||||||
<comment>Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles</comment>
|
<comment>Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (idempotent)</comment>
|
||||||
|
|
||||||
<!-- ADMIN: PRODUCTION_ORDER_READ -->
|
<sql>
|
||||||
<insert tableName="role_permissions">
|
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||||
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/>
|
('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_READ'),
|
||||||
<column name="permission" value="PRODUCTION_ORDER_READ"/>
|
('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_WRITE'),
|
||||||
</insert>
|
('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_READ'),
|
||||||
|
('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_WRITE')
|
||||||
<!-- ADMIN: PRODUCTION_ORDER_WRITE -->
|
ON CONFLICT DO NOTHING;
|
||||||
<insert tableName="role_permissions">
|
</sql>
|
||||||
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/>
|
|
||||||
<column name="permission" value="PRODUCTION_ORDER_WRITE"/>
|
|
||||||
</insert>
|
|
||||||
|
|
||||||
<!-- PRODUCTION_MANAGER: PRODUCTION_ORDER_READ -->
|
|
||||||
<insert tableName="role_permissions">
|
|
||||||
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
|
|
||||||
<column name="permission" value="PRODUCTION_ORDER_READ"/>
|
|
||||||
</insert>
|
|
||||||
|
|
||||||
<!-- PRODUCTION_MANAGER: PRODUCTION_ORDER_WRITE -->
|
|
||||||
<insert tableName="role_permissions">
|
|
||||||
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
|
|
||||||
<column name="permission" value="PRODUCTION_ORDER_WRITE"/>
|
|
||||||
</insert>
|
|
||||||
</changeSet>
|
</changeSet>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package de.effigenix.application.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.application.inventory.command.ReleaseReservationCommand;
|
||||||
|
import de.effigenix.domain.inventory.*;
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
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.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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("ReleaseReservation Use Case")
|
||||||
|
class ReleaseReservationTest {
|
||||||
|
|
||||||
|
@Mock private StockRepository stockRepository;
|
||||||
|
|
||||||
|
private ReleaseReservation releaseReservation;
|
||||||
|
private Stock existingStock;
|
||||||
|
private String reservationId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
releaseReservation = new ReleaseReservation(stockRepository);
|
||||||
|
|
||||||
|
var batch = StockBatch.reconstitute(
|
||||||
|
StockBatchId.generate(),
|
||||||
|
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||||
|
Quantity.reconstitute(new BigDecimal("50"), UnitOfMeasure.KILOGRAM),
|
||||||
|
LocalDate.of(2026, 12, 31),
|
||||||
|
StockBatchStatus.AVAILABLE,
|
||||||
|
Instant.now()
|
||||||
|
);
|
||||||
|
existingStock = Stock.reconstitute(
|
||||||
|
StockId.of("stock-1"),
|
||||||
|
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||||
|
StorageLocationId.of("location-1"),
|
||||||
|
null, null, new ArrayList<>(List.of(batch)), List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a reservation
|
||||||
|
var reserveResult = existingStock.reserve(
|
||||||
|
new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||||
|
reservationId = reserveResult.unsafeGetValue().id().value();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should release reservation successfully")
|
||||||
|
void shouldReleaseReservationSuccessfully() {
|
||||||
|
when(stockRepository.findById(StockId.of("stock-1")))
|
||||||
|
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||||
|
when(stockRepository.save(any())).thenReturn(Result.success(null));
|
||||||
|
|
||||||
|
var result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId));
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(existingStock.reservations()).isEmpty();
|
||||||
|
verify(stockRepository).save(existingStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||||
|
verify(stockRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail with ReservationNotFound when reservation does not exist in stock")
|
||||||
|
void shouldFailWhenReservationNotFoundInStock() {
|
||||||
|
when(stockRepository.findById(StockId.of("stock-1")))
|
||||||
|
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||||
|
|
||||||
|
var result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", "nonexistent"));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.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 result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId));
|
||||||
|
|
||||||
|
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 result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should release only targeted reservation when stock has multiple")
|
||||||
|
void shouldReleaseOnlyTargetedReservation() {
|
||||||
|
// Add a second reservation
|
||||||
|
var res2 = existingStock.reserve(
|
||||||
|
new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT"));
|
||||||
|
var secondReservationId = res2.unsafeGetValue().id().value();
|
||||||
|
assertThat(existingStock.reservations()).hasSize(2);
|
||||||
|
|
||||||
|
when(stockRepository.findById(StockId.of("stock-1")))
|
||||||
|
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||||
|
when(stockRepository.save(any())).thenReturn(Result.success(null));
|
||||||
|
|
||||||
|
var result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId));
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(existingStock.reservations()).hasSize(1);
|
||||||
|
assertThat(existingStock.reservations().getFirst().id().value()).isEqualTo(secondReservationId);
|
||||||
|
verify(stockRepository).save(existingStock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1788,6 +1788,163 @@ class StockTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== releaseReservation ====================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("releaseReservation()")
|
||||||
|
class ReleaseReservation {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should release reservation and restore available quantity")
|
||||||
|
void shouldReleaseReservationAndRestoreAvailableQuantity() {
|
||||||
|
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||||
|
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||||
|
|
||||||
|
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
|
||||||
|
assertThat(reserveResult.isSuccess()).isTrue();
|
||||||
|
var reservationId = reserveResult.unsafeGetValue().id();
|
||||||
|
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12"));
|
||||||
|
assertThat(stock.reservations()).hasSize(1);
|
||||||
|
|
||||||
|
var result = stock.releaseReservation(reservationId);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(stock.reservations()).isEmpty();
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail with ReservationNotFound when reservation does not exist")
|
||||||
|
void shouldFailWhenReservationNotFound() {
|
||||||
|
var stock = createValidStock();
|
||||||
|
|
||||||
|
var result = stock.releaseReservation(ReservationId.of("nonexistent"));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should keep other reservations intact when releasing one of multiple")
|
||||||
|
void shouldKeepOtherReservationsWhenReleasingOne() {
|
||||||
|
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||||
|
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||||
|
|
||||||
|
var res1 = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
|
||||||
|
var res2 = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "7", "KILOGRAM", "URGENT"));
|
||||||
|
assertThat(stock.reservations()).hasSize(2);
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("5"));
|
||||||
|
|
||||||
|
var result = stock.releaseReservation(res1.unsafeGetValue().id());
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(stock.reservations()).hasSize(1);
|
||||||
|
assertThat(stock.reservations().getFirst().referenceId()).isEqualTo("SO-001");
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("13"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should release reservation with cross-batch FEFO allocations")
|
||||||
|
void shouldReleaseCrossBatchAllocations() {
|
||||||
|
var batch1 = StockBatch.reconstitute(
|
||||||
|
StockBatchId.generate(),
|
||||||
|
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||||
|
Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
|
||||||
|
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||||
|
);
|
||||||
|
var batch2 = StockBatch.reconstitute(
|
||||||
|
StockBatchId.generate(),
|
||||||
|
new BatchReference("BATCH-002", BatchType.PRODUCED),
|
||||||
|
Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
|
||||||
|
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||||
|
);
|
||||||
|
var stock = Stock.reconstitute(
|
||||||
|
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||||
|
null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reservierung über beide Batches (6kg batch1 + 4kg batch2)
|
||||||
|
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||||
|
assertThat(reserveResult.unsafeGetValue().allocations()).hasSize(2);
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("4"));
|
||||||
|
|
||||||
|
var result = stock.releaseReservation(reserveResult.unsafeGetValue().id());
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(stock.reservations()).isEmpty();
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("14"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should allow new reservation after releasing previous one")
|
||||||
|
void shouldAllowNewReservationAfterRelease() {
|
||||||
|
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||||
|
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||||
|
|
||||||
|
// Vollständig reservieren
|
||||||
|
var res1 = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
// Neue Reservierung sollte fehlschlagen
|
||||||
|
var failResult = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "1", "KILOGRAM", "NORMAL"));
|
||||||
|
assertThat(failResult.isFailure()).isTrue();
|
||||||
|
|
||||||
|
// Freigeben und erneut reservieren
|
||||||
|
stock.releaseReservation(res1.unsafeGetValue().id());
|
||||||
|
var res2 = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "10", "KILOGRAM", "URGENT"));
|
||||||
|
assertThat(res2.isSuccess()).isTrue();
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should handle release when batch was later blocked")
|
||||||
|
void shouldReleaseWhenBatchLaterBlocked() {
|
||||||
|
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||||
|
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||||
|
var batchId = stock.batches().getFirst().id();
|
||||||
|
|
||||||
|
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||||
|
stock.blockBatch(batchId);
|
||||||
|
|
||||||
|
// availableQuantity == 0 (blocked batch)
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
var result = stock.releaseReservation(reserveResult.unsafeGetValue().id());
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(stock.reservations()).isEmpty();
|
||||||
|
// Batch still blocked → availableQuantity remains 0
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail with ReservationNotFound on empty reservations list")
|
||||||
|
void shouldFailOnEmptyReservationsList() {
|
||||||
|
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||||
|
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||||
|
|
||||||
|
var result = stock.releaseReservation(ReservationId.of("any-id"));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should restore fractional quantity after release")
|
||||||
|
void shouldRestoreFractionalQuantity() {
|
||||||
|
var stock = createStockWithBatchAndExpiry("10.5", UnitOfMeasure.KILOGRAM,
|
||||||
|
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||||
|
|
||||||
|
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5.25", "KILOGRAM", "NORMAL"));
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("5.25"));
|
||||||
|
|
||||||
|
stock.releaseReservation(reserveResult.unsafeGetValue().id());
|
||||||
|
|
||||||
|
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("10.5"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Helpers ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
private Stock createValidStock() {
|
private Stock createValidStock() {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import org.springframework.http.MediaType;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
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.request.MockMvcRequestBuilders.put;
|
||||||
|
|
@ -1197,6 +1198,198 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Reservierung freigeben (releaseReservation) ====================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("DELETE /{stockId}/reservations/{reservationId} – Reservierung freigeben")
|
||||||
|
class ReleaseReservationEndpoint {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Reservierung freigeben → 204, availableQuantity steigt")
|
||||||
|
void releaseReservation_returns204() throws Exception {
|
||||||
|
String stockId = createStock();
|
||||||
|
addBatchToStock(stockId); // 10 KILOGRAM
|
||||||
|
|
||||||
|
// Erst reservieren
|
||||||
|
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||||
|
var reserveResult = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
String reservationId = objectMapper.readTree(reserveResult.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
|
||||||
|
// availableQuantity == 5
|
||||||
|
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(jsonPath("$.availableQuantity").value(5));
|
||||||
|
|
||||||
|
// Reservierung freigeben
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
// availableQuantity == 10 (wiederhergestellt)
|
||||||
|
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(jsonPath("$.availableQuantity").value(10))
|
||||||
|
.andExpect(jsonPath("$.reservations.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Reservierung nicht gefunden → 404")
|
||||||
|
void releaseReservation_notFound_returns404() throws Exception {
|
||||||
|
String stockId = createStock();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, UUID.randomUUID().toString())
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("RESERVATION_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Stock nicht gefunden → 404")
|
||||||
|
void releaseReservation_stockNotFound_returns404() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}",
|
||||||
|
UUID.randomUUID().toString(), UUID.randomUUID().toString())
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Ohne STOCK_WRITE → 403")
|
||||||
|
void releaseReservation_withViewerToken_returns403() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}",
|
||||||
|
UUID.randomUUID().toString(), UUID.randomUUID().toString())
|
||||||
|
.header("Authorization", "Bearer " + viewerToken))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Ohne Token → 401")
|
||||||
|
void releaseReservation_withoutToken_returns401() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}",
|
||||||
|
UUID.randomUUID().toString(), UUID.randomUUID().toString()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Mehrere Reservierungen: eine freigeben, andere bleibt bestehen")
|
||||||
|
void releaseReservation_multipleReservations_keepsOthers() throws Exception {
|
||||||
|
String stockId = createStock();
|
||||||
|
addBatchToStock(stockId); // 10 KILOGRAM
|
||||||
|
|
||||||
|
// Reservierung 1: 6 kg
|
||||||
|
var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "6", "KILOGRAM", "NORMAL");
|
||||||
|
var res1 = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req1)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
String reservationId1 = objectMapper.readTree(res1.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
|
||||||
|
// Reservierung 2: 4 kg
|
||||||
|
var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "4", "KILOGRAM", "URGENT");
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req2)))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
// availableQuantity == 0
|
||||||
|
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(jsonPath("$.availableQuantity").value(0))
|
||||||
|
.andExpect(jsonPath("$.reservations.length()").value(2));
|
||||||
|
|
||||||
|
// Erste Reservierung freigeben
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId1)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
// Nur 6 kg frei, SO-001 bleibt
|
||||||
|
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(jsonPath("$.availableQuantity").value(6))
|
||||||
|
.andExpect(jsonPath("$.reservations.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.reservations[0].referenceId").value("SO-001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Idempotenz: zweites DELETE auf gleiche Reservierung → 404")
|
||||||
|
void releaseReservation_idempotent_returns404OnSecondCall() throws Exception {
|
||||||
|
String stockId = createStock();
|
||||||
|
addBatchToStock(stockId);
|
||||||
|
|
||||||
|
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||||
|
var reserveResult = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
String reservationId = objectMapper.readTree(reserveResult.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
|
||||||
|
// Erstes DELETE → 204
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
// Zweites DELETE → 404
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("RESERVATION_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Reserve → Release → erneut Reserve → verfügbare Menge korrekt")
|
||||||
|
void releaseReservation_thenReserveAgain() throws Exception {
|
||||||
|
String stockId = createStock();
|
||||||
|
addBatchToStock(stockId); // 10 KILOGRAM
|
||||||
|
|
||||||
|
// Alles reservieren
|
||||||
|
var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||||
|
var res1 = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req1)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
String reservationId = objectMapper.readTree(res1.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
|
||||||
|
// Erneute Reservierung schlägt fehl
|
||||||
|
var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "1", "KILOGRAM", "NORMAL");
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req2)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
|
||||||
|
|
||||||
|
// Freigeben
|
||||||
|
mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
// Jetzt geht es wieder
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req2)))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(jsonPath("$.availableQuantity").value(9))
|
||||||
|
.andExpect(jsonPath("$.reservations.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.reservations[0].referenceId").value("SO-001"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Hilfsmethoden ====================
|
// ==================== Hilfsmethoden ====================
|
||||||
|
|
||||||
private String createStorageLocation() throws Exception {
|
private String createStorageLocation() throws Exception {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue