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:
parent
600d0f9f06
commit
c047ca93de
31 changed files with 2745 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue