mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 17:49:57 +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:
parent
5224001dd7
commit
6feb3a9f1c
26 changed files with 1325 additions and 10 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue