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

fix(inventory): Review-Fixes für US-6.1 InventoryCount

- GlobalExceptionHandler und ErrorResponse nach infrastructure.shared
  extrahieren (war fälschlich in usermanagement)
- CountItem.deviation() prüft UOM-Kompatibilität
- InvalidInventoryCountId Error-Typ für null/blank ID (400 statt 404)
- saveChildren() auf UPSERT (UPDATE→INSERT) mit Orphan-Cleanup umstellen
- logger.trace → logger.warn bei DB-Fehlern
- Stocks ohne Batches in CreateInventoryCount überspringen
- AuthorizationPort Defense in Depth in alle 3 InventoryCount Use Cases
- Kombinierter DB-Index auf (storage_location_id, status)
This commit is contained in:
Sebastian Frick 2026-02-26 19:14:55 +01:00
parent c047ca93de
commit a214002fab
19 changed files with 205 additions and 130 deletions

View file

@ -8,6 +8,8 @@ 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 de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -31,12 +33,15 @@ class CreateInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private StockRepository stockRepository;
@Mock private UnitOfWork unitOfWork;
@Mock private AuthorizationPort authPort;
private CreateInventoryCount createInventoryCount;
private final ActorId actorId = ActorId.of("user-1");
@BeforeEach
void setUp() {
createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork);
createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
}
private void stubUnitOfWork() {
@ -86,7 +91,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
var count = result.unsafeGetValue();
@ -111,7 +116,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).isEmpty();
@ -125,7 +130,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(true));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.ActiveCountExists.class);
@ -136,7 +141,7 @@ class CreateInventoryCountTest {
void shouldFailWhenStorageLocationIdInvalid() {
var cmd = new CreateInventoryCountCommand("", LocalDate.now().toString(), "user-1");
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
@ -147,7 +152,7 @@ class CreateInventoryCountTest {
void shouldFailWhenCountDateInFuture() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().plusDays(1).toString(), "user-1");
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class);
@ -161,7 +166,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -177,7 +182,7 @@ class CreateInventoryCountTest {
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db down")));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -196,15 +201,15 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed")));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should handle stock without batches (zero quantity)")
void shouldHandleStockWithoutBatches() {
@DisplayName("should skip stocks without batches")
void shouldSkipStocksWithoutBatches() {
stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
@ -221,12 +226,10 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).hasSize(1);
assertThat(result.unsafeGetValue().countItems().getFirst().expectedQuantity().amount())
.isEqualByComparingTo(BigDecimal.ZERO);
assertThat(result.unsafeGetValue().countItems()).isEmpty();
}
@Test
@ -262,7 +265,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).hasSize(2);
@ -273,7 +276,7 @@ class CreateInventoryCountTest {
void shouldFailWhenInitiatedByNull() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), null);
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
@ -284,7 +287,7 @@ class CreateInventoryCountTest {
void shouldFailWhenCountDateUnparseable() {
var cmd = new CreateInventoryCountCommand("location-1", "not-a-date", "user-1");
var result = createInventoryCount.execute(cmd);
var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);

View file

@ -3,6 +3,8 @@ package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -16,6 +18,7 @@ import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@ -23,13 +26,16 @@ import static org.mockito.Mockito.when;
class GetInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private AuthorizationPort authPort;
private GetInventoryCount getInventoryCount;
private InventoryCount existingCount;
private final ActorId actorId = ActorId.of("user-1");
@BeforeEach
void setUp() {
getInventoryCount = new GetInventoryCount(inventoryCountRepository);
getInventoryCount = new GetInventoryCount(inventoryCountRepository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
existingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"),
@ -48,7 +54,7 @@ class GetInventoryCountTest {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(existingCount)));
var result = getInventoryCount.execute("count-1");
var result = getInventoryCount.execute("count-1", actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().id().value()).isEqualTo("count-1");
@ -60,7 +66,7 @@ class GetInventoryCountTest {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.empty()));
var result = getInventoryCount.execute("count-1");
var result = getInventoryCount.execute("count-1", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
@ -72,36 +78,36 @@ class GetInventoryCountTest {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = getInventoryCount.execute("count-1");
var result = getInventoryCount.execute("count-1", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with InventoryCountNotFound when id is null")
@DisplayName("should fail with InvalidInventoryCountId when id is null")
void shouldFailWhenIdIsNull() {
var result = getInventoryCount.execute(null);
var result = getInventoryCount.execute(null, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
@Test
@DisplayName("should fail with InventoryCountNotFound when id is blank")
@DisplayName("should fail with InvalidInventoryCountId when id is blank")
void shouldFailWhenIdIsBlank() {
var result = getInventoryCount.execute(" ");
var result = getInventoryCount.execute(" ", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
@Test
@DisplayName("should fail with InventoryCountNotFound when id is empty string")
@DisplayName("should fail with InvalidInventoryCountId when id is empty string")
void shouldFailWhenIdIsEmpty() {
var result = getInventoryCount.execute("");
var result = getInventoryCount.execute("", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
}

View file

@ -3,6 +3,8 @@ package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -15,6 +17,7 @@ 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.*;
@ExtendWith(MockitoExtension.class)
@ -22,14 +25,17 @@ import static org.mockito.Mockito.*;
class ListInventoryCountsTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private AuthorizationPort authPort;
private ListInventoryCounts listInventoryCounts;
private InventoryCount count1;
private InventoryCount count2;
private final ActorId actorId = ActorId.of("user-1");
@BeforeEach
void setUp() {
listInventoryCounts = new ListInventoryCounts(inventoryCountRepository);
listInventoryCounts = new ListInventoryCounts(inventoryCountRepository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
count1 = InventoryCount.reconstitute(
InventoryCountId.of("count-1"),
@ -57,7 +63,7 @@ class ListInventoryCountsTest {
void shouldReturnAllCountsWhenNoFilter() {
when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2)));
var result = listInventoryCounts.execute(null);
var result = listInventoryCounts.execute(null, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
@ -70,7 +76,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(count1)));
var result = listInventoryCounts.execute("location-1");
var result = listInventoryCounts.execute("location-1", actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
@ -84,7 +90,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findAll())
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute(null);
var result = listInventoryCounts.execute(null, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -96,7 +102,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute("location-1");
var result = listInventoryCounts.execute("location-1", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -108,7 +114,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown")))
.thenReturn(Result.success(List.of()));
var result = listInventoryCounts.execute("unknown");
var result = listInventoryCounts.execute("unknown", actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
@ -117,7 +123,7 @@ class ListInventoryCountsTest {
@Test
@DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId")
void shouldFailWhenBlankStorageLocationId() {
var result = listInventoryCounts.execute(" ");
var result = listInventoryCounts.execute(" ", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);

View file

@ -429,6 +429,20 @@ class InventoryCountTest {
assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("2.5"));
}
@Test
@DisplayName("should return null when UOMs differ")
void shouldReturnNullWhenUomsDiffer() {
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.LITER)
);
assertThat(item.isCounted()).isTrue();
assertThat(item.deviation()).isNull();
}
@Test
@DisplayName("should compute zero deviation (actual == expected)")
void shouldComputeZeroDeviation() {