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

feat(inventory): Charge einbuchen (addBatch) (#5)

StockBatch als Child-Entity im Stock-Aggregat mit BatchReference
(batchId + batchType), Quantity, ExpiryDate und Status AVAILABLE.
POST /api/inventory/stocks/{stockId}/batches → 201.
This commit is contained in:
Sebastian Frick 2026-02-19 22:26:24 +01:00
parent 5224001dd7
commit 6feb3a9f1c
26 changed files with 1325 additions and 10 deletions

View file

@ -0,0 +1,180 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.AddStockBatchCommand;
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.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("AddStockBatch Use Case")
class AddStockBatchTest {
@Mock private StockRepository stockRepository;
private AddStockBatch addStockBatch;
private AddStockBatchCommand validCommand;
private Stock existingStock;
@BeforeEach
void setUp() {
addStockBatch = new AddStockBatch(stockRepository);
existingStock = Stock.reconstitute(
StockId.of("stock-1"),
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null, List.of()
);
validCommand = new AddStockBatchCommand(
"stock-1", "BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31"
);
}
@Test
@DisplayName("should add batch to existing stock")
void shouldAddBatchToExistingStock() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
var result = addStockBatch.execute(validCommand);
assertThat(result.isSuccess()).isTrue();
var batch = result.unsafeGetValue();
assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
assertThat(batch.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
assertThat(batch.receivedAt()).isNotNull();
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 = addStockBatch.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 = addStockBatch.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 = addStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with DuplicateBatchReference when batch already exists")
void shouldFailWhenDuplicateBatchReference() {
var stockWithBatch = Stock.reconstitute(
StockId.of("stock-1"),
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null,
List.of(StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM, null, null),
LocalDate.of(2026, 12, 31),
StockBatchStatus.AVAILABLE,
Instant.now()
))
);
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(stockWithBatch)));
var result = addStockBatch.execute(validCommand);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class);
verify(stockRepository, never()).save(any());
}
@Test
@DisplayName("should fail with InvalidBatchReference when batchType is invalid")
void shouldFailWhenInvalidBatchType() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31");
var result = addStockBatch.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
verify(stockRepository, never()).save(any());
}
@Test
@DisplayName("should fail with InvalidQuantity when quantity is negative")
void shouldFailWhenInvalidQuantity() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31");
var result = addStockBatch.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
verify(stockRepository, never()).save(any());
}
@Test
@DisplayName("should fail with InvalidExpiryDate when date format is wrong")
void shouldFailWhenInvalidExpiryDate() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "PRODUCED", "10", "KILOGRAM", "not-a-date");
var result = addStockBatch.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
verify(stockRepository, never()).save(any());
}
}

View file

@ -0,0 +1,115 @@
package de.effigenix.domain.inventory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BatchReference")
class BatchReferenceTest {
@Nested
@DisplayName("of()")
class Of {
@Test
@DisplayName("should create with PRODUCED type")
void shouldCreateWithProducedType() {
var result = BatchReference.of("BATCH-001", "PRODUCED");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().batchId()).isEqualTo("BATCH-001");
assertThat(result.unsafeGetValue().batchType()).isEqualTo(BatchType.PRODUCED);
}
@Test
@DisplayName("should create with PURCHASED type")
void shouldCreateWithPurchasedType() {
var result = BatchReference.of("PO-123", "PURCHASED");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().batchId()).isEqualTo("PO-123");
assertThat(result.unsafeGetValue().batchType()).isEqualTo(BatchType.PURCHASED);
}
@Test
@DisplayName("should fail when batchId is null")
void shouldFailWhenBatchIdNull() {
var result = BatchReference.of(null, "PRODUCED");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchId is blank")
void shouldFailWhenBatchIdBlank() {
var result = BatchReference.of(" ", "PRODUCED");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchType is null")
void shouldFailWhenBatchTypeNull() {
var result = BatchReference.of("BATCH-001", null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchType is invalid")
void shouldFailWhenBatchTypeInvalid() {
var result = BatchReference.of("BATCH-001", "INVALID");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
assertThat(result.unsafeGetError().message()).contains("INVALID");
}
@Test
@DisplayName("should fail when batchType is empty string")
void shouldFailWhenBatchTypeEmpty() {
var result = BatchReference.of("BATCH-001", "");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
}
}
@Nested
@DisplayName("equals / hashCode")
class Equality {
@Test
@DisplayName("should be equal with same batchId and batchType")
void shouldBeEqualWithSameValues() {
var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED);
var ref2 = new BatchReference("BATCH-001", BatchType.PRODUCED);
assertThat(ref1).isEqualTo(ref2);
assertThat(ref1.hashCode()).isEqualTo(ref2.hashCode());
}
@Test
@DisplayName("should not be equal with different batchType")
void shouldNotBeEqualWithDifferentType() {
var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED);
var ref2 = new BatchReference("BATCH-001", BatchType.PURCHASED);
assertThat(ref1).isNotEqualTo(ref2);
}
@Test
@DisplayName("should not be equal with different batchId")
void shouldNotBeEqualWithDifferentId() {
var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED);
var ref2 = new BatchReference("BATCH-002", BatchType.PRODUCED);
assertThat(ref1).isNotEqualTo(ref2);
}
}
}

View file

@ -0,0 +1,176 @@
package de.effigenix.domain.inventory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class StockBatchTest {
// ==================== Create ====================
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create batch with valid data")
void shouldCreateWithValidData() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isSuccess()).isTrue();
var batch = result.unsafeGetValue();
assertThat(batch.id()).isNotNull();
assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
assertThat(batch.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
assertThat(batch.expiryDate().toString()).isEqualTo("2026-12-31");
assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
assertThat(batch.receivedAt()).isNotNull();
}
@Test
@DisplayName("should create batch with PURCHASED type")
void shouldCreateWithPurchasedType() {
var draft = new StockBatchDraft("PO-123", "PURCHASED", "5", "PIECE", "2027-01-15");
var result = StockBatch.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().batchReference().batchType()).isEqualTo(BatchType.PURCHASED);
}
@Test
@DisplayName("should fail when batchId is blank")
void shouldFailWhenBatchIdBlank() {
var draft = new StockBatchDraft("", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchType is invalid")
void shouldFailWhenBatchTypeInvalid() {
var draft = new StockBatchDraft("BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when quantity is negative")
void shouldFailWhenQuantityNegative() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantity is zero")
void shouldFailWhenQuantityZero() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "0", "KILOGRAM", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantity is not a number")
void shouldFailWhenQuantityNotNumber() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "abc", "KILOGRAM", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when unit is invalid")
void shouldFailWhenUnitInvalid() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "INVALID_UNIT", "2026-12-31");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when expiryDate is blank")
void shouldFailWhenExpiryDateBlank() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
}
@Test
@DisplayName("should fail when expiryDate is null")
void shouldFailWhenExpiryDateNull() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", null);
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
}
@Test
@DisplayName("should fail when expiryDate is not ISO format")
void shouldFailWhenExpiryDateInvalidFormat() {
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "31.12.2026");
var result = StockBatch.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
}
}
// ==================== Equality ====================
@Nested
@DisplayName("equals / hashCode")
class Equality {
@Test
@DisplayName("should be equal if same ID")
void shouldBeEqualBySameId() {
var id = StockBatchId.generate();
var ref = new BatchReference("B1", BatchType.PRODUCED);
var batch1 = StockBatch.reconstitute(id, ref, null, null, StockBatchStatus.AVAILABLE, null);
var batch2 = StockBatch.reconstitute(id, ref, null, null, StockBatchStatus.BLOCKED, null);
assertThat(batch1).isEqualTo(batch2);
assertThat(batch1.hashCode()).isEqualTo(batch2.hashCode());
}
@Test
@DisplayName("should not be equal if different ID")
void shouldNotBeEqualByDifferentId() {
var ref = new BatchReference("B1", BatchType.PRODUCED);
var batch1 = StockBatch.reconstitute(StockBatchId.generate(), ref, null, null, StockBatchStatus.AVAILABLE, null);
var batch2 = StockBatch.reconstitute(StockBatchId.generate(), ref, null, null, StockBatchStatus.AVAILABLE, null);
assertThat(batch1).isNotEqualTo(batch2);
}
}
}

View file

@ -8,6 +8,7 @@ import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -37,6 +38,7 @@ class StockTest {
assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(stock.minimumShelfLife()).isNotNull();
assertThat(stock.minimumShelfLife().days()).isEqualTo(30);
assertThat(stock.batches()).isEmpty();
}
@Test
@ -187,6 +189,68 @@ class StockTest {
}
}
// ==================== addBatch ====================
@Nested
@DisplayName("addBatch()")
class AddBatch {
@Test
@DisplayName("should add batch successfully")
void shouldAddBatch() {
var stock = createValidStock();
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
var result = stock.addBatch(draft);
assertThat(result.isSuccess()).isTrue();
var batch = result.unsafeGetValue();
assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
assertThat(stock.batches()).hasSize(1);
}
@Test
@DisplayName("should fail when duplicate batch reference")
void shouldFailOnDuplicateBatchReference() {
var stock = createValidStock();
var draft1 = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
stock.addBatch(draft1);
var draft2 = new StockBatchDraft("BATCH-001", "PRODUCED", "5", "KILOGRAM", "2027-01-15");
var result = stock.addBatch(draft2);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class);
}
@Test
@DisplayName("should allow same batchId with different batchType")
void shouldAllowSameBatchIdDifferentType() {
var stock = createValidStock();
stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"));
var result = stock.addBatch(new StockBatchDraft("BATCH-001", "PURCHASED", "5", "KILOGRAM", "2027-01-15"));
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches()).hasSize(2);
}
@Test
@DisplayName("should return unmodifiable batches list")
void shouldReturnUnmodifiableBatchesList() {
var stock = createValidStock();
stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"));
var batches = stock.batches();
assertThat(batches).hasSize(1);
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
() -> batches.add(null));
}
}
// ==================== Reconstitute ====================
@Nested
@ -203,13 +267,14 @@ class StockTest {
var minimumLevel = new MinimumLevel(quantity);
var minimumShelfLife = new MinimumShelfLife(30);
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife);
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of());
assertThat(stock.id()).isEqualTo(id);
assertThat(stock.articleId()).isEqualTo(articleId);
assertThat(stock.storageLocationId()).isEqualTo(locationId);
assertThat(stock.minimumLevel()).isEqualTo(minimumLevel);
assertThat(stock.minimumShelfLife()).isEqualTo(minimumShelfLife);
assertThat(stock.batches()).isEmpty();
}
@Test
@ -219,7 +284,7 @@ class StockTest {
var articleId = ArticleId.of("article-1");
var locationId = StorageLocationId.of("location-1");
var stock = Stock.reconstitute(id, articleId, locationId, null, null);
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of());
assertThat(stock.minimumLevel()).isNull();
assertThat(stock.minimumShelfLife()).isNull();
@ -236,8 +301,8 @@ class StockTest {
@DisplayName("should be equal if same ID")
void shouldBeEqualBySameId() {
var id = StockId.generate();
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null);
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null);
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of());
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of());
assertThat(stock1).isEqualTo(stock2);
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());

View file

@ -2,11 +2,13 @@ package de.effigenix.infrastructure.inventory.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@ -21,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*
* Abgedeckte Testfälle:
* - Story 2.1 Bestandsposition anlegen
* - Story 2.2 Charge einbuchen (addBatch)
*/
@DisplayName("Stock Controller Integration Tests")
class StockControllerIntegrationTest extends AbstractIntegrationTest {
@ -201,6 +204,147 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(status().isUnauthorized());
}
// ==================== Charge einbuchen (addBatch) ====================
@Nested
@DisplayName("POST /{stockId}/batches Charge einbuchen")
class AddBatch {
@Test
@DisplayName("Charge einbuchen → 201")
void addBatch_returns201() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.batchId").value("BATCH-001"))
.andExpect(jsonPath("$.batchType").value("PRODUCED"))
.andExpect(jsonPath("$.quantityAmount").value(10.5))
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.expiryDate").value("2026-12-31"))
.andExpect(jsonPath("$.status").value("AVAILABLE"))
.andExpect(jsonPath("$.receivedAt").isNotEmpty());
}
@Test
@DisplayName("Charge mit PURCHASED Typ → 201")
void addBatch_purchased_returns201() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("PO-123", "PURCHASED", "5", "PIECE", "2027-01-15");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.batchType").value("PURCHASED"));
}
@Test
@DisplayName("Doppelte BatchReference → 409")
void addBatch_duplicate_returns409() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_BATCH_REFERENCE"));
}
@Test
@DisplayName("Ungültiger BatchType → 400")
void addBatch_invalidBatchType_returns400() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_BATCH_REFERENCE"));
}
@Test
@DisplayName("Ungültige Menge → 400")
void addBatch_invalidQuantity_returns400() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
}
@Test
@DisplayName("Ungültiges Ablaufdatum → 400")
void addBatch_invalidExpiryDate_returns400() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "31.12.2026");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_EXPIRY_DATE"));
}
@Test
@DisplayName("Stock nicht gefunden → 404")
void addBatch_stockNotFound_returns404() throws Exception {
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
}
@Test
@DisplayName("Charge einbuchen ohne STOCK_WRITE → 403")
void addBatch_withViewerToken_returns403() throws Exception {
String stockId = createStock();
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Charge einbuchen ohne Token → 401")
void addBatch_withoutToken_returns401() throws Exception {
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception {
@ -217,4 +361,18 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
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);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
}