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

feat(inventory): Inventur anlegen und Zählpositionen befüllen (US-6.1)

InventoryCount-Aggregate mit CountItem-Entities, Auto-Populate aus
Stock-Daten, vollständige DDD-Schichten inkl. Edge-Case-Tests und
Jazzer-Fuzz-Test. 1909 Tests grün.
This commit is contained in:
Sebastian Frick 2026-02-26 11:59:39 +01:00
parent 600d0f9f06
commit c047ca93de
31 changed files with 2745 additions and 0 deletions

View file

@ -0,0 +1,292 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.CreateInventoryCountCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
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 de.effigenix.shared.persistence.UnitOfWork;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("CreateInventoryCount Use Case")
class CreateInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private StockRepository stockRepository;
@Mock private UnitOfWork unitOfWork;
private CreateInventoryCount createInventoryCount;
@BeforeEach
void setUp() {
createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork);
}
private void stubUnitOfWork() {
when(unitOfWork.executeAtomically(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
var supplier = (java.util.function.Supplier<Result<?, ?>>) invocation.getArgument(0);
return supplier.get();
});
}
@Test
@DisplayName("should create inventory count and auto-populate from stocks")
void shouldCreateAndAutoPopulate() {
stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(false));
var stock = Stock.reconstitute(
StockId.of("stock-1"), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
List.of(
StockBatch.reconstitute(
StockBatchId.of("batch-1"),
new BatchReference("B001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10.5"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(30),
StockBatchStatus.AVAILABLE,
Instant.now()
),
StockBatch.reconstitute(
StockBatchId.of("batch-2"),
new BatchReference("B002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(60),
StockBatchStatus.AVAILABLE,
Instant.now()
)
),
List.of()
);
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(stock)));
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
assertThat(result.isSuccess()).isTrue();
var count = result.unsafeGetValue();
assertThat(count.storageLocationId().value()).isEqualTo("location-1");
assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN);
assertThat(count.countItems()).hasSize(1);
assertThat(count.countItems().getFirst().articleId().value()).isEqualTo("article-1");
assertThat(count.countItems().getFirst().expectedQuantity().amount())
.isEqualByComparingTo(new BigDecimal("15.5"));
}
@Test
@DisplayName("should create empty inventory count when no stocks exist")
void shouldCreateEmptyWhenNoStocks() {
stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(false));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of()));
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).isEmpty();
}
@Test
@DisplayName("should fail when active count already exists")
void shouldFailWhenActiveCountExists() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(true));
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.ActiveCountExists.class);
}
@Test
@DisplayName("should fail when storageLocationId is invalid")
void shouldFailWhenStorageLocationIdInvalid() {
var cmd = new CreateInventoryCountCommand("", LocalDate.now().toString(), "user-1");
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should fail when countDate is in the future")
void shouldFailWhenCountDateInFuture() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().plusDays(1).toString(), "user-1");
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class);
}
@Test
@DisplayName("should fail when repository check fails")
void shouldFailWhenRepositoryCheckFails() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock repository fails")
void shouldFailWhenStockRepositoryFails() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(false));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db down")));
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when save fails")
void shouldFailWhenSaveFails() {
stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(false));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of()));
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed")));
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should handle stock without batches (zero quantity)")
void shouldHandleStockWithoutBatches() {
stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(false));
var stock = Stock.reconstitute(
StockId.of("stock-1"), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, List.of(), List.of()
);
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(stock)));
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).hasSize(1);
assertThat(result.unsafeGetValue().countItems().getFirst().expectedQuantity().amount())
.isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("should handle multiple stocks for same location")
void shouldHandleMultipleStocks() {
stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(false));
var stock1 = Stock.reconstitute(
StockId.of("stock-1"), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
List.of(StockBatch.reconstitute(
StockBatchId.of("batch-1"), new BatchReference("B001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(30), StockBatchStatus.AVAILABLE, Instant.now()
)), List.of()
);
var stock2 = Stock.reconstitute(
StockId.of("stock-2"), ArticleId.of("article-2"), StorageLocationId.of("location-1"),
null, null,
List.of(StockBatch.reconstitute(
StockBatchId.of("batch-2"), new BatchReference("B002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("20.0"), UnitOfMeasure.LITER),
LocalDate.now().plusDays(60), StockBatchStatus.AVAILABLE, Instant.now()
)), List.of()
);
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(stock1, stock2)));
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).hasSize(2);
}
@Test
@DisplayName("should fail when initiatedBy is null")
void shouldFailWhenInitiatedByNull() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), null);
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when countDate is unparseable")
void shouldFailWhenCountDateUnparseable() {
var cmd = new CreateInventoryCountCommand("location-1", "not-a-date", "user-1");
var result = createInventoryCount.execute(cmd);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);
}
}

View file

@ -0,0 +1,107 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
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.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.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("GetInventoryCount Use Case")
class GetInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
private GetInventoryCount getInventoryCount;
private InventoryCount existingCount;
@BeforeEach
void setUp() {
getInventoryCount = new GetInventoryCount(inventoryCountRepository);
existingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"),
StorageLocationId.of("location-1"),
LocalDate.now(),
"user-1",
InventoryCountStatus.OPEN,
Instant.now(),
List.of()
);
}
@Test
@DisplayName("should return inventory count when found")
void shouldReturnCountWhenFound() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(existingCount)));
var result = getInventoryCount.execute("count-1");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().id().value()).isEqualTo("count-1");
}
@Test
@DisplayName("should fail with InventoryCountNotFound when not found")
void shouldFailWhenNotFound() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.empty()));
var result = getInventoryCount.execute("count-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails")
void shouldFailWhenRepositoryFails() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = getInventoryCount.execute("count-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with InventoryCountNotFound when id is null")
void shouldFailWhenIdIsNull() {
var result = getInventoryCount.execute(null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
}
@Test
@DisplayName("should fail with InventoryCountNotFound when id is blank")
void shouldFailWhenIdIsBlank() {
var result = getInventoryCount.execute(" ");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
}
@Test
@DisplayName("should fail with InventoryCountNotFound when id is empty string")
void shouldFailWhenIdIsEmpty() {
var result = getInventoryCount.execute("");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
}
}

View file

@ -0,0 +1,125 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
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.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("ListInventoryCounts Use Case")
class ListInventoryCountsTest {
@Mock private InventoryCountRepository inventoryCountRepository;
private ListInventoryCounts listInventoryCounts;
private InventoryCount count1;
private InventoryCount count2;
@BeforeEach
void setUp() {
listInventoryCounts = new ListInventoryCounts(inventoryCountRepository);
count1 = InventoryCount.reconstitute(
InventoryCountId.of("count-1"),
StorageLocationId.of("location-1"),
LocalDate.now(),
"user-1",
InventoryCountStatus.OPEN,
Instant.now(),
List.of()
);
count2 = InventoryCount.reconstitute(
InventoryCountId.of("count-2"),
StorageLocationId.of("location-2"),
LocalDate.now(),
"user-1",
InventoryCountStatus.COMPLETED,
Instant.now(),
List.of()
);
}
@Test
@DisplayName("should return all counts when no filter provided")
void shouldReturnAllCountsWhenNoFilter() {
when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2)));
var result = listInventoryCounts.execute(null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
verify(inventoryCountRepository).findAll();
}
@Test
@DisplayName("should filter by storageLocationId")
void shouldFilterByStorageLocationId() {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(count1)));
var result = listInventoryCounts.execute("location-1");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(inventoryCountRepository).findByStorageLocationId(StorageLocationId.of("location-1"));
verify(inventoryCountRepository, never()).findAll();
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails for findAll")
void shouldFailWhenRepositoryFailsForFindAll() {
when(inventoryCountRepository.findAll())
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute(null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails for storageLocationId filter")
void shouldFailWhenRepositoryFailsForFilter() {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute("location-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should return empty list when no counts match")
void shouldReturnEmptyListWhenNoCountsMatch() {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown")))
.thenReturn(Result.success(List.of()));
var result = listInventoryCounts.execute("unknown");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId")
void shouldFailWhenBlankStorageLocationId() {
var result = listInventoryCounts.execute(" ");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
}
}

View file

@ -0,0 +1,61 @@
package de.effigenix.domain.inventory;
import com.code_intelligence.jazzer.api.FuzzedDataProvider;
import com.code_intelligence.jazzer.junit.FuzzTest;
import de.effigenix.shared.common.Result;
/**
* Fuzz test for the InventoryCount aggregate.
*
* Exercises create() with fuzzed drafts, then applies a random sequence of
* addCountItem() operations. Verifies that no input combination causes an
* unhandled exception all invalid inputs must be caught and returned as
* Result.Failure.
*
* Run: make fuzz | make fuzz/single TEST=InventoryCountFuzzTest
*/
class InventoryCountFuzzTest {
@FuzzTest(maxDuration = "5m")
void fuzzInventoryCount(FuzzedDataProvider data) {
var draft = new InventoryCountDraft(
data.consumeString(50),
data.consumeString(30),
data.consumeString(30)
);
switch (InventoryCount.create(draft)) {
case Result.Failure(var err) -> { }
case Result.Success(var count) -> {
int ops = data.consumeInt(1, 20);
for (int i = 0; i < ops; i++) {
var itemDraft = new CountItemDraft(
data.consumeString(50),
data.consumeString(30),
data.consumeString(20)
);
switch (count.addCountItem(itemDraft)) {
case Result.Failure(var err) -> { }
case Result.Success(var item) -> {
// Verify computed properties don't throw
item.deviation();
item.isCounted();
item.id();
item.articleId();
item.expectedQuantity();
}
}
}
// Verify aggregate getters don't throw
count.isActive();
count.countItems();
count.id();
count.storageLocationId();
count.countDate();
count.status();
count.initiatedBy();
count.createdAt();
}
}
}
}

View file

@ -0,0 +1,588 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class InventoryCountTest {
// ==================== Create ====================
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create InventoryCount with valid inputs")
void shouldCreateWithValidInputs() {
var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isSuccess()).isTrue();
var count = result.unsafeGetValue();
assertThat(count.id()).isNotNull();
assertThat(count.storageLocationId().value()).isEqualTo("location-1");
assertThat(count.countDate()).isEqualTo(LocalDate.now());
assertThat(count.initiatedBy()).isEqualTo("user-1");
assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN);
assertThat(count.countItems()).isEmpty();
}
@Test
@DisplayName("should create InventoryCount with past date")
void shouldCreateWithPastDate() {
var draft = new InventoryCountDraft("location-1", "2025-01-15", "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countDate()).isEqualTo(LocalDate.of(2025, 1, 15));
}
@Test
@DisplayName("should fail when storageLocationId is null")
void shouldFailWhenStorageLocationIdNull() {
var draft = new InventoryCountDraft(null, LocalDate.now().toString(), "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should fail when storageLocationId is blank")
void shouldFailWhenStorageLocationIdBlank() {
var draft = new InventoryCountDraft(" ", LocalDate.now().toString(), "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should fail when countDate is null")
void shouldFailWhenCountDateNull() {
var draft = new InventoryCountDraft("location-1", null, "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);
}
@Test
@DisplayName("should fail when countDate is invalid")
void shouldFailWhenCountDateInvalid() {
var draft = new InventoryCountDraft("location-1", "not-a-date", "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);
}
@Test
@DisplayName("should fail when countDate is in the future")
void shouldFailWhenCountDateInFuture() {
var futureDate = LocalDate.now().plusDays(1).toString();
var draft = new InventoryCountDraft("location-1", futureDate, "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class);
}
@Test
@DisplayName("should fail when initiatedBy is null")
void shouldFailWhenInitiatedByNull() {
var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), null);
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when initiatedBy is blank")
void shouldFailWhenInitiatedByBlank() {
var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), " ");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when storageLocationId is empty string")
void shouldFailWhenStorageLocationIdEmpty() {
var draft = new InventoryCountDraft("", LocalDate.now().toString(), "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should fail when initiatedBy is empty string")
void shouldFailWhenInitiatedByEmpty() {
var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), "");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when countDate is blank")
void shouldFailWhenCountDateBlank() {
var draft = new InventoryCountDraft("location-1", " ", "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);
}
@Test
@DisplayName("should accept today as countDate (boundary)")
void shouldAcceptTodayAsCountDate() {
var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countDate()).isEqualTo(LocalDate.now());
}
@Test
@DisplayName("should fail with invalid date like 2025-13-01")
void shouldFailWhenDateHasInvalidMonth() {
var draft = new InventoryCountDraft("location-1", "2025-13-01", "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);
}
@Test
@DisplayName("should fail with invalid date like 2025-02-30")
void shouldFailWhenDateHasInvalidDay() {
var draft = new InventoryCountDraft("location-1", "2025-02-30", "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);
}
@Test
@DisplayName("should accept leap year date 2024-02-29")
void shouldAcceptLeapYearDate() {
var draft = new InventoryCountDraft("location-1", "2024-02-29", "user-1");
var result = InventoryCount.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countDate()).isEqualTo(LocalDate.of(2024, 2, 29));
}
}
// ==================== addCountItem ====================
@Nested
@DisplayName("addCountItem()")
class AddCountItem {
@Test
@DisplayName("should add count item to OPEN count")
void shouldAddCountItemToOpenCount() {
var count = createOpenCount();
var itemDraft = new CountItemDraft("article-1", "10.0", "KILOGRAM");
var result = count.addCountItem(itemDraft);
assertThat(result.isSuccess()).isTrue();
assertThat(count.countItems()).hasSize(1);
assertThat(result.unsafeGetValue().articleId().value()).isEqualTo("article-1");
}
@Test
@DisplayName("should add multiple count items with different articles")
void shouldAddMultipleCountItems() {
var count = createOpenCount();
count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
count.addCountItem(new CountItemDraft("article-2", "5.0", "LITER"));
assertThat(count.countItems()).hasSize(2);
}
@Test
@DisplayName("should fail when adding duplicate articleId")
void shouldFailWhenDuplicateArticle() {
var count = createOpenCount();
count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
var result = count.addCountItem(new CountItemDraft("article-1", "5.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.DuplicateArticle.class);
}
@Test
@DisplayName("should fail when count is not OPEN")
void shouldFailWhenNotOpen() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"),
StorageLocationId.of("location-1"),
LocalDate.now(),
"user-1",
InventoryCountStatus.COUNTING,
java.time.Instant.now(),
java.util.List.of()
);
var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when articleId is blank")
void shouldFailWhenArticleIdBlank() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("", "10.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidArticleId.class);
}
@Test
@DisplayName("should fail when quantity amount is invalid")
void shouldFailWhenQuantityInvalid() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("article-1", "not-a-number", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when unit is invalid")
void shouldFailWhenUnitInvalid() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "INVALID_UNIT"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class);
}
@Test
@DisplayName("should allow zero expected quantity")
void shouldAllowZeroExpectedQuantity() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("article-1", "0", "KILOGRAM"));
assertThat(result.isSuccess()).isTrue();
}
@Test
@DisplayName("should fail when count is COMPLETED")
void shouldFailWhenCompleted() {
var count = reconstitute(InventoryCountStatus.COMPLETED);
var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when count is CANCELLED")
void shouldFailWhenCancelled() {
var count = reconstitute(InventoryCountStatus.CANCELLED);
var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when quantity is negative")
void shouldFailWhenNegativeQuantity() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("article-1", "-5.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when articleId is null")
void shouldFailWhenArticleIdNull() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft(null, "10.0", "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidArticleId.class);
}
@Test
@DisplayName("should fail when quantity amount is null")
void shouldFailWhenQuantityAmountNull() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("article-1", null, "KILOGRAM"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when unit is null")
void shouldFailWhenUnitNull() {
var count = createOpenCount();
var result = count.addCountItem(new CountItemDraft("article-1", "10.0", null));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class);
}
private InventoryCount reconstitute(InventoryCountStatus status) {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", status,
Instant.now(), List.of()
);
}
}
// ==================== CountItem deviation ====================
@Nested
@DisplayName("CountItem deviation()")
class Deviation {
@Test
@DisplayName("should return null when not yet counted")
void shouldReturnNullWhenNotCounted() {
var item = CountItem.create(new CountItemDraft("article-1", "10.0", "KILOGRAM")).unsafeGetValue();
assertThat(item.deviation()).isNull();
assertThat(item.isCounted()).isFalse();
}
@Test
@DisplayName("should compute negative deviation (actual < expected)")
void shouldComputeNegativeDeviation() {
var item = CountItem.reconstitute(
CountItemId.of("item-1"),
ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM)
);
assertThat(item.isCounted()).isTrue();
assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("-2.0"));
}
@Test
@DisplayName("should compute positive deviation (actual > expected)")
void shouldComputePositiveDeviation() {
var item = CountItem.reconstitute(
CountItemId.of("item-1"),
ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("12.5"), UnitOfMeasure.KILOGRAM)
);
assertThat(item.isCounted()).isTrue();
assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("2.5"));
}
@Test
@DisplayName("should compute zero deviation (actual == expected)")
void shouldComputeZeroDeviation() {
var item = CountItem.reconstitute(
CountItemId.of("item-1"),
ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM)
);
assertThat(item.isCounted()).isTrue();
assertThat(item.deviation()).isEqualByComparingTo(BigDecimal.ZERO);
}
}
// ==================== isActive ====================
@Nested
@DisplayName("isActive()")
class IsActive {
@Test
@DisplayName("OPEN should be active")
void openShouldBeActive() {
var count = createOpenCount();
assertThat(count.isActive()).isTrue();
}
@Test
@DisplayName("COUNTING should be active")
void countingShouldBeActive() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
Instant.now(), List.of()
);
assertThat(count.isActive()).isTrue();
}
@Test
@DisplayName("COMPLETED should not be active")
void completedShouldNotBeActive() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED,
Instant.now(), List.of()
);
assertThat(count.isActive()).isFalse();
}
@Test
@DisplayName("CANCELLED should not be active")
void cancelledShouldNotBeActive() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.CANCELLED,
Instant.now(), List.of()
);
assertThat(count.isActive()).isFalse();
}
}
// ==================== reconstitute ====================
@Nested
@DisplayName("reconstitute()")
class Reconstitute {
@Test
@DisplayName("should reconstitute with countItems")
void shouldReconstituteWithCountItems() {
var items = List.of(
CountItem.reconstitute(
CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null
),
CountItem.reconstitute(
CountItemId.of("item-2"), ArticleId.of("article-2"),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER), null
)
);
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.of(2025, 6, 15), "user-1", InventoryCountStatus.COUNTING,
Instant.now(), items
);
assertThat(count.id().value()).isEqualTo("count-1");
assertThat(count.status()).isEqualTo(InventoryCountStatus.COUNTING);
assertThat(count.countItems()).hasSize(2);
}
@Test
@DisplayName("countItems should be unmodifiable")
void countItemsShouldBeUnmodifiable() {
var count = createOpenCount();
assertThatThrownBy(() -> count.countItems().add(
CountItem.reconstitute(
CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(BigDecimal.TEN, UnitOfMeasure.KILOGRAM), null
)
)).isInstanceOf(UnsupportedOperationException.class);
}
}
// ==================== equals / hashCode ====================
@Nested
@DisplayName("equals() / hashCode()")
class EqualsAndHashCode {
@Test
@DisplayName("same id → equal")
void sameIdShouldBeEqual() {
var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"),
LocalDate.now(), "user-a", InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
var b = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"),
LocalDate.now().minusDays(1), "user-b", InventoryCountStatus.COMPLETED,
Instant.now(), List.of()
);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("different id → not equal")
void differentIdShouldNotBeEqual() {
var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
var b = InventoryCount.reconstitute(
InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
assertThat(a).isNotEqualTo(b);
}
}
// ==================== Helpers ====================
private InventoryCount createOpenCount() {
return InventoryCount.create(
new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1")
).unsafeGetValue();
}
}

View file

@ -0,0 +1,339 @@
package de.effigenix.infrastructure.inventory.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
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.result.MockMvcResultMatchers.*;
/**
* Integrationstests für InventoryCountController.
*
* Abgedeckte Testfälle:
* - US-6.1 Inventur anlegen und Zählpositionen befüllen
*/
@DisplayName("InventoryCount Controller Integration Tests")
class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
private String storageLocationId;
@BeforeEach
void setUp() throws Exception {
String adminRoleId = createRole(RoleName.ADMIN, "Admin");
String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
String adminId = createUser("ic.admin", "ic.admin@test.com", Set.of(adminRoleId), "BRANCH-01");
String viewerId = createUser("ic.viewer", "ic.viewer@test.com", Set.of(viewerRoleId), "BRANCH-01");
adminToken = generateToken(adminId, "ic.admin", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ");
viewerToken = generateToken(viewerId, "ic.viewer", "USER_READ");
storageLocationId = createStorageLocation();
}
// ==================== Inventur anlegen ====================
@Test
@DisplayName("Inventur anlegen → 201 mit Pflichtfeldern")
void createInventoryCount_returns201() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.storageLocationId").value(storageLocationId))
.andExpect(jsonPath("$.countDate").value(LocalDate.now().toString()))
.andExpect(jsonPath("$.status").value("OPEN"))
.andExpect(jsonPath("$.countItems").isArray());
}
// ==================== Auto-Populate ====================
@Test
@DisplayName("Inventur mit vorhandenen Stocks → countItems werden befüllt")
void createInventoryCount_withStocks_autoPopulates() throws Exception {
// Stock mit Batch anlegen
String articleId = createArticleId();
createStockWithBatch(articleId, storageLocationId);
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.countItems.length()").value(1))
.andExpect(jsonPath("$.countItems[0].articleId").value(articleId))
.andExpect(jsonPath("$.countItems[0].expectedQuantityAmount").isNumber());
}
// ==================== Duplikat (aktive Inventur) ====================
@Test
@DisplayName("Zweite aktive Inventur für gleichen Lagerort → 409")
void createInventoryCount_activeExists_returns409() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
// Erste Inventur anlegen
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
// Zweite Inventur für gleichen Lagerort 409
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ACTIVE_COUNT_EXISTS"));
}
// ==================== GET by ID ====================
@Test
@DisplayName("Inventur per ID abfragen → 200")
void getInventoryCount_returns200() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
var createResult = mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
mockMvc.perform(get("/api/inventory/inventory-counts/" + id)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.storageLocationId").value(storageLocationId));
}
@Test
@DisplayName("Inventur mit unbekannter ID → 404")
void getInventoryCount_notFound_returns404() throws Exception {
mockMvc.perform(get("/api/inventory/inventory-counts/" + UUID.randomUUID())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("INVENTORY_COUNT_NOT_FOUND"));
}
// ==================== GET all / filtered ====================
@Test
@DisplayName("Alle Inventuren auflisten → 200")
void listInventoryCounts_returns200() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Inventuren nach storageLocationId filtern → 200")
void listInventoryCounts_filterByStorageLocation_returns200() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/inventory/inventory-counts")
.param("storageLocationId", storageLocationId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].storageLocationId").value(storageLocationId));
}
// ==================== Security ====================
@Test
@DisplayName("Ohne Token → 401")
void createInventoryCount_noToken_returns401() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne INVENTORY_COUNT_WRITE Permission → 403")
void createInventoryCount_noPermission_returns403() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
// ==================== Validation Edge Cases ====================
@Test
@DisplayName("Inventur mit zukünftigem Datum → 400")
void createInventoryCount_futureDate_returns400() throws Exception {
var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().plusDays(1).toString());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("COUNT_DATE_IN_FUTURE"));
}
@Test
@DisplayName("Inventur mit ungültigem Datumsformat → 400")
void createInventoryCount_invalidDateFormat_returns400() throws Exception {
String json = """
{"storageLocationId": "%s", "countDate": "not-a-date"}
""".formatted(storageLocationId);
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_COUNT_DATE"));
}
@Test
@DisplayName("Inventur ohne storageLocationId → 400")
void createInventoryCount_missingStorageLocationId_returns400() throws Exception {
String json = """
{"countDate": "%s"}
""".formatted(LocalDate.now());
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Inventur ohne countDate → 400")
void createInventoryCount_missingCountDate_returns400() throws Exception {
String json = """
{"storageLocationId": "%s"}
""".formatted(storageLocationId);
mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
}
// ==================== GET Security Edge Cases ====================
@Test
@DisplayName("GET by ID ohne Token → 401")
void getInventoryCount_noToken_returns401() throws Exception {
mockMvc.perform(get("/api/inventory/inventory-counts/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("GET by ID ohne INVENTORY_COUNT_READ Permission → 403")
void getInventoryCount_noPermission_returns403() throws Exception {
mockMvc.perform(get("/api/inventory/inventory-counts/" + UUID.randomUUID())
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("GET all ohne Token → 401")
void listInventoryCounts_noToken_returns401() throws Exception {
mockMvc.perform(get("/api/inventory/inventory-counts"))
.andExpect(status().isUnauthorized());
}
// ==================== Leere Liste ====================
@Test
@DisplayName("Liste ohne Inventuren → leere Liste 200")
void listInventoryCounts_empty_returnsEmptyList() throws Exception {
mockMvc.perform(get("/api/inventory/inventory-counts")
.param("storageLocationId", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
}
// ==================== Helpers ====================
private String createStorageLocation() throws Exception {
String json = """
{"name": "Testlager-%s", "storageType": "DRY_STORAGE"}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private void createStockWithBatch(String articleId, String storageLocationId) throws Exception {
String stockJson = """
{"articleId": "%s", "storageLocationId": "%s"}
""".formatted(articleId, storageLocationId);
var stockResult = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(stockJson))
.andExpect(status().isCreated())
.andReturn();
String stockId = objectMapper.readTree(stockResult.getResponse().getContentAsString()).get("id").asText();
String batchJson = """
{"batchId": "B-%s", "batchType": "PRODUCED", "quantityAmount": "25.0", "quantityUnit": "KILOGRAM", "expiryDate": "%s"}
""".formatted(UUID.randomUUID().toString().substring(0, 8), LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/inventory/stocks/" + stockId + "/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(batchJson))
.andExpect(status().isCreated());
}
}