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

feat(inventory): Inventur abbrechen und nach Status filtern (US-6.4)

Ermöglicht das Abbrechen von Inventuren (OPEN/COUNTING → CANCELLED) mit
Pflicht-Begründung sowie das Filtern der Inventurliste nach Status.
This commit is contained in:
Sebastian Frick 2026-03-19 11:39:56 +01:00
parent 58ed0a3810
commit a0ebf46329
28 changed files with 798 additions and 47 deletions

View file

@ -0,0 +1,170 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.CancelInventoryCountCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
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;
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.ArrayList;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("CancelInventoryCount Use Case")
class CancelInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private CancelInventoryCount cancelInventoryCount;
private ActorId performedBy;
@BeforeEach
void setUp() {
cancelInventoryCount = new CancelInventoryCount(inventoryCountRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private CancelInventoryCountCommand validCommand() {
return new CancelInventoryCountCommand("count-1", "Nicht mehr benötigt");
}
private InventoryCount countWithStatus(InventoryCountStatus status) {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, status,
null, Instant.now(), new ArrayList<>()
);
}
@Test
@DisplayName("should cancel OPEN inventory count")
void should_Cancel_OpenCount() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.OPEN))));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.CANCELLED);
assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Nicht mehr benötigt");
verify(inventoryCountRepository).save(any(InventoryCount.class));
}
@Test
@DisplayName("should cancel COUNTING inventory count")
void should_Cancel_CountingCount() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.COUNTING))));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.CANCELLED);
assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Nicht mehr benötigt");
verify(inventoryCountRepository).save(any(InventoryCount.class));
}
@Test
@DisplayName("should fail when status is COMPLETED")
void should_Fail_When_Completed() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.COMPLETED))));
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
verify(inventoryCountRepository, never()).save(any());
}
@Test
@DisplayName("should fail when status is already CANCELLED")
void should_Fail_When_AlreadyCancelled() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.CANCELLED))));
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
verify(inventoryCountRepository, never()).save(any());
}
@Test
@DisplayName("should fail when inventory count not found")
void should_Fail_When_NotFound() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.empty()));
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
verify(inventoryCountRepository, never()).save(any());
}
@Test
@DisplayName("should fail when unauthorized")
void should_Fail_When_Unauthorized() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(false);
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class);
verify(inventoryCountRepository, never()).save(any());
}
@Test
@DisplayName("should fail when repository findById returns error")
void should_Fail_When_RepositoryError() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = cancelInventoryCount.execute(validCommand(), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when reason is blank")
void should_Fail_When_ReasonBlank() {
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.OPEN))));
var result = cancelInventoryCount.execute(
new CancelInventoryCountCommand("count-1", " "), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class);
verify(inventoryCountRepository, never()).save(any());
}
}

View file

@ -237,7 +237,7 @@ class CompleteInventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
@ -256,7 +256,7 @@ class CompleteInventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), new ArrayList<>()
null, Instant.now(), new ArrayList<>()
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
@ -372,7 +372,7 @@ class CompleteInventoryCountTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
}

View file

@ -44,6 +44,7 @@ class GetInventoryCountTest {
"user-1",
null,
InventoryCountStatus.OPEN,
null,
Instant.now(),
List.of()
);

View file

@ -44,6 +44,7 @@ class ListInventoryCountsTest {
"user-1",
null,
InventoryCountStatus.OPEN,
null,
Instant.now(),
List.of()
);
@ -55,6 +56,7 @@ class ListInventoryCountsTest {
"user-1",
null,
InventoryCountStatus.COMPLETED,
null,
Instant.now(),
List.of()
);
@ -65,7 +67,7 @@ class ListInventoryCountsTest {
void shouldReturnAllCountsWhenNoFilter() {
when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2)));
var result = listInventoryCounts.execute(null, actorId);
var result = listInventoryCounts.execute(null, null, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
@ -78,7 +80,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(count1)));
var result = listInventoryCounts.execute("location-1", actorId);
var result = listInventoryCounts.execute("location-1", null, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
@ -92,7 +94,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findAll())
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute(null, actorId);
var result = listInventoryCounts.execute(null, null, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -104,7 +106,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute("location-1", actorId);
var result = listInventoryCounts.execute("location-1", null, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -116,7 +118,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown")))
.thenReturn(Result.success(List.of()));
var result = listInventoryCounts.execute("unknown", actorId);
var result = listInventoryCounts.execute("unknown", null, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
@ -125,9 +127,57 @@ class ListInventoryCountsTest {
@Test
@DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId")
void shouldFailWhenBlankStorageLocationId() {
var result = listInventoryCounts.execute(" ", actorId);
var result = listInventoryCounts.execute(" ", null, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should filter by status only")
void shouldFilterByStatusOnly() {
when(inventoryCountRepository.findByStatus(InventoryCountStatus.OPEN))
.thenReturn(Result.success(List.of(count1)));
var result = listInventoryCounts.execute(null, "OPEN", actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(inventoryCountRepository).findByStatus(InventoryCountStatus.OPEN);
verify(inventoryCountRepository, never()).findAll();
}
@Test
@DisplayName("should filter by storageLocationId and status")
void shouldFilterByStorageLocationIdAndStatus() {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(count1, count2)));
var result = listInventoryCounts.execute("location-1", "OPEN", actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
assertThat(result.unsafeGetValue().getFirst().status()).isEqualTo(InventoryCountStatus.OPEN);
}
@Test
@DisplayName("should fail with InvalidStatus for invalid status string")
void shouldFailWhenInvalidStatus() {
var result = listInventoryCounts.execute(null, "INVALID", actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatus.class);
}
@Test
@DisplayName("should fail when unauthorized")
void shouldFailWhenUnauthorized() {
reset(authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(false);
var result = listInventoryCounts.execute(null, null, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class);
}
}

View file

@ -56,7 +56,7 @@ class RecordCountItemTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of(
null, Instant.now(), 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"),
@ -227,7 +227,7 @@ class RecordCountItemTest {
var openCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of(
null, Instant.now(), List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
)

View file

@ -59,7 +59,7 @@ class StartInventoryCountTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), items
null, Instant.now(), items
);
}
@ -111,7 +111,7 @@ class StartInventoryCountTest {
var emptyCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
@ -129,7 +129,7 @@ class StartInventoryCountTest {
var countingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of(
null, Instant.now(), List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
)

View file

@ -225,7 +225,7 @@ class InventoryCountReconciliationServiceTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), items
null, Instant.now(), items
);
var stocks = List.of(stockFor("article-1", "stock-1"));
@ -241,7 +241,7 @@ class InventoryCountReconciliationServiceTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), new ArrayList<>(items)
null, Instant.now(), new ArrayList<>(items)
);
}

View file

@ -260,6 +260,7 @@ class InventoryCountTest {
"user-1",
null,
InventoryCountStatus.COUNTING,
null,
java.time.Instant.now(),
java.util.List.of()
);
@ -383,7 +384,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, status,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
}
}
@ -454,7 +455,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, status,
Instant.now(), items
null, Instant.now(), items
);
}
}
@ -527,7 +528,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), items
null, Instant.now(), items
);
var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM);
@ -601,7 +602,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER);
@ -704,7 +705,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
assertThat(count.isActive()).isTrue();
}
@ -715,7 +716,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
assertThat(count.isActive()).isFalse();
}
@ -726,7 +727,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
assertThat(count.isActive()).isFalse();
}
@ -755,7 +756,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
assertThat(count.id().value()).isEqualTo("count-1");
@ -789,12 +790,12 @@ class InventoryCountTest {
var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"),
LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
var b = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"),
LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
assertThat(a).isEqualTo(b);
@ -807,12 +808,12 @@ class InventoryCountTest {
var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
var b = InventoryCount.reconstitute(
InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
null, Instant.now(), List.of()
);
assertThat(a).isNotEqualTo(b);
@ -861,7 +862,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
var result = count.complete("user-2");
@ -888,7 +889,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), createCountedItems()
null, Instant.now(), createCountedItems()
);
var result = count.complete("user-3");
@ -903,7 +904,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), createCountedItems()
null, Instant.now(), createCountedItems()
);
var result = count.complete("user-2");
@ -940,7 +941,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), new ArrayList<>()
null, Instant.now(), new ArrayList<>()
);
var result = count.complete("user-2");
@ -963,7 +964,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
var result = count.complete("user-2");
@ -1010,7 +1011,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
}
@ -1023,6 +1024,100 @@ class InventoryCountTest {
}
}
// ==================== cancel ====================
@Nested
@DisplayName("cancel()")
class Cancel {
@Test
@DisplayName("should cancel from OPEN status")
void shouldCancelFromOpen() {
var count = createOpenCount();
var result = count.cancel("Nicht mehr benötigt");
assertThat(result.isSuccess()).isTrue();
assertThat(count.status()).isEqualTo(InventoryCountStatus.CANCELLED);
assertThat(count.cancellationReason()).isEqualTo("Nicht mehr benötigt");
}
@Test
@DisplayName("should cancel from COUNTING status")
void shouldCancelFromCounting() {
var count = createCountingCount();
var result = count.cancel("Falsche Artikel");
assertThat(result.isSuccess()).isTrue();
assertThat(count.status()).isEqualTo(InventoryCountStatus.CANCELLED);
assertThat(count.cancellationReason()).isEqualTo("Falsche Artikel");
}
@Test
@DisplayName("should fail when status is COMPLETED")
void shouldFailWhenStatusIsCompleted() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
null, Instant.now(), new ArrayList<>()
);
var result = count.cancel("Zu spät");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when status is already CANCELLED")
void shouldFailWhenAlreadyCancelled() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
"Alter Grund", Instant.now(), new ArrayList<>()
);
var result = count.cancel("Neuer Grund");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when reason is null")
void shouldFailWhenReasonNull() {
var count = createOpenCount();
var result = count.cancel(null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class);
}
@Test
@DisplayName("should fail when reason is blank")
void shouldFailWhenReasonBlank() {
var count = createOpenCount();
var result = count.cancel(" ");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class);
}
@Test
@DisplayName("should not modify status when validation fails")
void shouldNotModifyStatusOnFailure() {
var count = createOpenCount();
count.cancel(null);
assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN);
assertThat(count.cancellationReason()).isNull();
}
}
// ==================== Helpers ====================
private InventoryCount createOpenCount() {
@ -1036,7 +1131,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
null, Instant.now(), items
);
}

View file

@ -695,6 +695,158 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
}
}
// ==================== Inventur abbrechen (US-6.4) ====================
@Nested
@DisplayName("US-6.4 Inventur abbrechen und abfragen")
class CancelAndFilter {
@Test
@DisplayName("Inventur abbrechen → 200 mit Grund und Status CANCELLED")
void cancelInventoryCount_returns200() throws Exception {
String countId = createInventoryCountWithStock();
String body = """
{"reason": "Falsche Artikel ausgewählt"}
""";
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CANCELLED"))
.andExpect(jsonPath("$.cancellationReason").value("Falsche Artikel ausgewählt"));
}
@Test
@DisplayName("Inventur abbrechen ohne Grund → 400")
void cancelInventoryCount_noReason_returns400() throws Exception {
String countId = createInventoryCountWithStock();
String body = """
{"reason": ""}
""";
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Abgeschlossene Inventur abbrechen → 409")
void cancelInventoryCount_completed_returns409() throws Exception {
String countId = createAndCompleteCount();
String body = """
{"reason": "Zu spät bemerkt"}
""";
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Filter nach Status OPEN → nur offene Inventuren")
void listInventoryCounts_filterByStatus_returnsFiltered() throws Exception {
// Create two counts: one open, one will be cancelled
createInventoryCountWithStock();
// Need a second storage location for a second count
String storageLocationId2 = createStorageLocation();
String articleId2 = createArticleId();
createStockWithBatch(articleId2, storageLocationId2);
var request2 = new CreateInventoryCountRequest(storageLocationId2, LocalDate.now().toString());
var result2 = mockMvc.perform(post("/api/inventory/inventory-counts")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request2)))
.andExpect(status().isCreated())
.andReturn();
String countId2 = objectMapper.readTree(result2.getResponse().getContentAsString()).get("id").asText();
// Cancel one
String cancelBody = """
{"reason": "Nicht benötigt"}
""";
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId2 + "/cancel")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelBody))
.andExpect(status().isOk());
// Filter by OPEN
mockMvc.perform(get("/api/inventory/inventory-counts?status=OPEN")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].status").value("OPEN"));
}
@Test
@DisplayName("Filter nach ungültigem Status → 400")
void listInventoryCounts_invalidStatus_returns400() throws Exception {
mockMvc.perform(get("/api/inventory/inventory-counts?status=INVALID")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_STATUS"));
}
@Test
@DisplayName("Inventur abbrechen ohne Token → 401")
void cancelInventoryCount_noToken_returns401() throws Exception {
String body = """
{"reason": "Test"}
""";
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/cancel")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Inventur abbrechen ohne Berechtigung → 403")
void cancelInventoryCount_noPermission_returns403() throws Exception {
String body = """
{"reason": "Test"}
""";
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/cancel")
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isForbidden());
}
private String createAndCompleteCount() throws Exception {
String countId = createInventoryCountWithStock();
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
String itemId = getFirstCountItemId(countId);
String recordBody = """
{"actualQuantityAmount": "25.0", "actualQuantityUnit": "KILOGRAM"}
""";
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(recordBody))
.andExpect(status().isOk());
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk());
return countId;
}
}
// ==================== Helpers ====================
private String createInventoryCountWithStock() throws Exception {