1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 17:39:57 +01:00

feat(inventory): Charge sperren/entsperren (blockBatch/unblockBatch) (#7)

Gesperrte Chargen können nicht entnommen oder reserviert werden.
blockBatch: AVAILABLE/EXPIRING_SOON → BLOCKED; unblockBatch: BLOCKED → AVAILABLE/EXPIRING_SOON (MHD-Check).
This commit is contained in:
Sebastian Frick 2026-02-19 23:46:34 +01:00
parent 8a9d2bfc30
commit e7c3258f07
15 changed files with 934 additions and 1 deletions

View file

@ -0,0 +1,158 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.BlockStockBatchCommand;
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("BlockStockBatch Use Case")
class BlockStockBatchTest {
@Mock private StockRepository stockRepository;
private BlockStockBatch blockStockBatch;
private StockBatchId batchId;
private Stock existingStock;
private BlockStockBatchCommand validCommand;
@BeforeEach
void setUp() {
blockStockBatch = new BlockStockBatch(stockRepository);
batchId = StockBatchId.of("batch-1");
var batch = StockBatch.reconstitute(
batchId,
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
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))
);
validCommand = new BlockStockBatchCommand("stock-1", "batch-1", "Quality issue");
}
@Test
@DisplayName("should block batch successfully")
void shouldBlockBatchSuccessfully() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
var result = blockStockBatch.execute(validCommand);
assertThat(result.isSuccess()).isTrue();
verify(stockRepository).save(existingStock);
assertThat(existingStock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
}
@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 = blockStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.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 = blockStockBatch.execute(validCommand);
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 = blockStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should propagate domain error for batch not found")
void shouldPropagateBatchNotFound() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
var cmd = new BlockStockBatchCommand("stock-1", "nonexistent", "Quality issue");
var result = blockStockBatch.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
verify(stockRepository, never()).save(any());
}
@Test
@DisplayName("should propagate BatchAlreadyBlocked when batch is already blocked")
void shouldPropagateBatchAlreadyBlocked() {
var blockedBatch = StockBatch.reconstitute(
batchId,
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
LocalDate.of(2026, 12, 31),
StockBatchStatus.BLOCKED,
Instant.now()
);
var stock = Stock.reconstitute(
StockId.of("stock-1"),
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(blockedBatch))
);
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(stock)));
var result = blockStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchAlreadyBlocked.class);
verify(stockRepository, never()).save(any());
}
}

View file

@ -0,0 +1,158 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
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("UnblockStockBatch Use Case")
class UnblockStockBatchTest {
@Mock private StockRepository stockRepository;
private UnblockStockBatch unblockStockBatch;
private StockBatchId batchId;
private Stock existingStock;
private UnblockStockBatchCommand validCommand;
@BeforeEach
void setUp() {
unblockStockBatch = new UnblockStockBatch(stockRepository);
batchId = StockBatchId.of("batch-1");
var batch = StockBatch.reconstitute(
batchId,
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
LocalDate.of(2026, 12, 31),
StockBatchStatus.BLOCKED,
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))
);
validCommand = new UnblockStockBatchCommand("stock-1", "batch-1");
}
@Test
@DisplayName("should unblock batch successfully")
void shouldUnblockBatchSuccessfully() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
var result = unblockStockBatch.execute(validCommand);
assertThat(result.isSuccess()).isTrue();
verify(stockRepository).save(existingStock);
assertThat(existingStock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
}
@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 = unblockStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.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 = unblockStockBatch.execute(validCommand);
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 = unblockStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should propagate domain error for batch not found")
void shouldPropagateBatchNotFound() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
var cmd = new UnblockStockBatchCommand("stock-1", "nonexistent");
var result = unblockStockBatch.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
verify(stockRepository, never()).save(any());
}
@Test
@DisplayName("should propagate BatchNotBlocked when batch is not blocked")
void shouldPropagateBatchNotBlocked() {
var availableBatch = StockBatch.reconstitute(
batchId,
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
LocalDate.of(2026, 12, 31),
StockBatchStatus.AVAILABLE,
Instant.now()
);
var stock = Stock.reconstitute(
StockId.of("stock-1"),
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(availableBatch))
);
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(stock)));
var result = unblockStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotBlocked.class);
verify(stockRepository, never()).save(any());
}
}

View file

@ -435,6 +435,166 @@ class StockTest {
}
}
// ==================== blockBatch ====================
@Nested
@DisplayName("blockBatch()")
class BlockBatch {
@Test
@DisplayName("should block AVAILABLE batch")
void shouldBlockAvailableBatch() {
var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
var batchId = stock.batches().getFirst().id();
var result = stock.blockBatch(batchId);
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
}
@Test
@DisplayName("should block EXPIRING_SOON batch")
void shouldBlockExpiringSoonBatch() {
var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRING_SOON);
var batchId = stock.batches().getFirst().id();
var result = stock.blockBatch(batchId);
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
}
@Test
@DisplayName("should fail when batch is already BLOCKED")
void shouldFailWhenAlreadyBlocked() {
var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED);
var batchId = stock.batches().getFirst().id();
var result = stock.blockBatch(batchId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchAlreadyBlocked.class);
}
@Test
@DisplayName("should fail when batch is EXPIRED")
void shouldFailWhenExpired() {
var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRED);
var batchId = stock.batches().getFirst().id();
var result = stock.blockBatch(batchId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class);
}
@Test
@DisplayName("should fail when batch not found")
void shouldFailWhenBatchNotFound() {
var stock = createValidStock();
var result = stock.blockBatch(StockBatchId.of("nonexistent"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
}
}
// ==================== unblockBatch ====================
@Nested
@DisplayName("unblockBatch()")
class UnblockBatch {
@Test
@DisplayName("should unblock to AVAILABLE when no minimum shelf life")
void shouldUnblockToAvailable() {
var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED);
var batchId = stock.batches().getFirst().id();
var result = stock.unblockBatch(batchId);
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
}
@Test
@DisplayName("should unblock to EXPIRING_SOON when MHD check triggers")
void shouldUnblockToExpiringSoon() {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
LocalDate.now().plusDays(5),
StockBatchStatus.BLOCKED,
Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(),
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30),
new ArrayList<>(List.of(batch))
);
var batchId = stock.batches().getFirst().id();
var result = stock.unblockBatch(batchId);
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
}
@Test
@DisplayName("should unblock to AVAILABLE when MHD check passes")
void shouldUnblockToAvailableWhenMhdPasses() {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
LocalDate.now().plusDays(60),
StockBatchStatus.BLOCKED,
Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(),
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30),
new ArrayList<>(List.of(batch))
);
var batchId = stock.batches().getFirst().id();
var result = stock.unblockBatch(batchId);
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
}
@Test
@DisplayName("should fail when batch is not BLOCKED")
void shouldFailWhenNotBlocked() {
var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
var batchId = stock.batches().getFirst().id();
var result = stock.unblockBatch(batchId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotBlocked.class);
}
@Test
@DisplayName("should fail when batch not found")
void shouldFailWhenBatchNotFound() {
var stock = createValidStock();
var result = stock.unblockBatch(StockBatchId.of("nonexistent"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
}
}
// ==================== Helpers ====================
private Stock createValidStock() {

View file

@ -345,6 +345,209 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
}
}
// ==================== Charge sperren (blockBatch) ====================
@Nested
@DisplayName("POST /{stockId}/batches/{batchId}/block Charge sperren")
class BlockBatch {
@Test
@DisplayName("Charge sperren → 200")
void blockBatch_returns200() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue detected"}
"""))
.andExpect(status().isOk());
}
@Test
@DisplayName("Bereits gesperrte Charge erneut sperren → 409")
void blockBatch_alreadyBlocked_returns409() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue detected"}
"""))
.andExpect(status().isOk());
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Another reason"}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_ALREADY_BLOCKED"));
}
@Test
@DisplayName("Charge sperren Batch nicht gefunden → 404")
void blockBatch_batchNotFound_returns404() throws Exception {
String stockId = createStock();
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue"}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Charge sperren Stock nicht gefunden → 404")
void blockBatch_stockNotFound_returns404() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block",
UUID.randomUUID().toString(), UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue"}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
}
@Test
@DisplayName("Charge sperren ohne reason → 400")
void blockBatch_withoutReason_returns400() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": ""}
"""))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Charge sperren ohne STOCK_WRITE → 403")
void blockBatch_withViewerToken_returns403() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue"}
"""))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Charge sperren ohne Token → 401")
void blockBatch_withoutToken_returns401() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block",
UUID.randomUUID().toString(), UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue"}
"""))
.andExpect(status().isUnauthorized());
}
}
// ==================== Charge entsperren (unblockBatch) ====================
@Nested
@DisplayName("POST /{stockId}/batches/{batchId}/unblock Charge entsperren")
class UnblockBatch {
@Test
@DisplayName("Gesperrte Charge entsperren → 200")
void unblockBatch_returns200() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
// Erst sperren
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue"}
"""))
.andExpect(status().isOk());
// Dann entsperren
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
@Test
@DisplayName("Nicht gesperrte Charge entsperren → 409")
void unblockBatch_notBlocked_returns409() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_NOT_BLOCKED"));
}
@Test
@DisplayName("Charge entsperren Batch nicht gefunden → 404")
void unblockBatch_batchNotFound_returns404() throws Exception {
String stockId = createStock();
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Charge entsperren Stock nicht gefunden → 404")
void unblockBatch_stockNotFound_returns404() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock",
UUID.randomUUID().toString(), UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
}
@Test
@DisplayName("Charge entsperren ohne STOCK_WRITE → 403")
void unblockBatch_withViewerToken_returns403() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, batchId)
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Charge entsperren ohne Token → 401")
void unblockBatch_withoutToken_returns401() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock",
UUID.randomUUID().toString(), UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception {
@ -362,6 +565,21 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String addBatchToStock(String stockId) throws Exception {
var request = new AddStockBatchRequest(
"BATCH-" + UUID.randomUUID().toString().substring(0, 8),
"PRODUCED", "10", "KILOGRAM", "2026-12-31");
var result = mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createStock() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, null);