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

feat(inventory): Bestandsbewegungen abfragen mit Zeitraum- und Chargen-Filter – Issue #16

Erweitert die StockMovement-Abfrage um batchReference- und from/to-Filter
mit Filter-Priorität stockId > articleId > batchReference > movementType > from/to.
Inkl. DB-Index auf batch_id, Unit-/Integrationstests und Lasttest-Szenarien.
This commit is contained in:
Sebastian Frick 2026-02-25 08:37:46 +01:00
parent fa6c0c2d70
commit 0e5d8f7025
13 changed files with 416 additions and 26 deletions

View file

@ -62,7 +62,7 @@ class ListStockMovementsTest {
void shouldReturnAll() {
when(repository.findAll()).thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, null, actor);
var result = useCase.execute(null, null, null, null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
@ -74,7 +74,7 @@ class ListStockMovementsTest {
void shouldReturnEmptyList() {
when(repository.findAll()).thenReturn(Result.success(List.of()));
var result = useCase.execute(null, null, null, actor);
var result = useCase.execute(null, null, null, null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
@ -86,7 +86,7 @@ class ListStockMovementsTest {
when(repository.findAll()).thenReturn(
Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = useCase.execute(null, null, null, actor);
var result = useCase.execute(null, null, null, null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
@ -103,7 +103,7 @@ class ListStockMovementsTest {
when(repository.findAllByStockId(StockId.of("stock-1")))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute("stock-1", null, null, actor);
var result = useCase.execute("stock-1", null, null, null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
@ -114,7 +114,7 @@ class ListStockMovementsTest {
@Test
@DisplayName("should fail with InvalidStockId when format invalid")
void shouldFailWhenStockIdInvalid() {
var result = useCase.execute(" ", null, null, actor);
var result = useCase.execute(" ", null, null, null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
@ -131,7 +131,7 @@ class ListStockMovementsTest {
when(repository.findAllByArticleId(ArticleId.of("article-1")))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, "article-1", null, actor);
var result = useCase.execute(null, "article-1", null, null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
@ -141,13 +141,55 @@ class ListStockMovementsTest {
@Test
@DisplayName("should fail with InvalidArticleId when format invalid")
void shouldFailWhenArticleIdInvalid() {
var result = useCase.execute(null, " ", null, actor);
var result = useCase.execute(null, " ", null, null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class);
}
}
@Nested
@DisplayName("Filter by batchReference")
class BatchReferenceFilter {
@Test
@DisplayName("should filter by batchReference")
void shouldFilterByBatchReference() {
when(repository.findAllByBatchId("CHARGE-001"))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(repository).findAllByBatchId("CHARGE-001");
}
@Test
@DisplayName("should return empty list when no movements for batch")
void shouldReturnEmptyForUnknownBatch() {
when(repository.findAllByBatchId("UNKNOWN"))
.thenReturn(Result.success(List.of()));
var result = useCase.execute(null, null, null, "UNKNOWN", null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail when repository fails for batchReference")
void shouldFailWhenRepositoryFailsForBatch() {
when(repository.findAllByBatchId("CHARGE-001"))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
}
}
@Nested
@DisplayName("Filter by movementType")
class MovementTypeFilter {
@ -158,7 +200,7 @@ class ListStockMovementsTest {
when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, "GOODS_RECEIPT", actor);
var result = useCase.execute(null, null, "GOODS_RECEIPT", null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
@ -168,43 +210,182 @@ class ListStockMovementsTest {
@Test
@DisplayName("should fail with InvalidMovementType when type invalid")
void shouldFailWhenMovementTypeInvalid() {
var result = useCase.execute(null, null, "INVALID_TYPE", actor);
var result = useCase.execute(null, null, "INVALID_TYPE", null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class);
}
}
@Nested
@DisplayName("Filter by date range")
class DateRangeFilter {
private final Instant from = Instant.parse("2026-01-01T00:00:00Z");
private final Instant to = Instant.parse("2026-12-31T23:59:59Z");
@Test
@DisplayName("should filter by from and to")
void shouldFilterByFromAndTo() {
when(repository.findAllByPerformedAtBetween(from, to))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, null, null, from, to, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(repository).findAllByPerformedAtBetween(from, to);
}
@Test
@DisplayName("should filter with only from (open-ended)")
void shouldFilterByFromOnly() {
when(repository.findAllByPerformedAtBetween(eq(from), any()))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, null, null, from, null, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByPerformedAtBetween(eq(from), eq(Instant.MAX));
}
@Test
@DisplayName("should filter with only to (open-ended)")
void shouldFilterByToOnly() {
when(repository.findAllByPerformedAtBetween(any(), eq(to)))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, null, null, null, to, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByPerformedAtBetween(eq(Instant.MIN), eq(to));
}
@Test
@DisplayName("should fail with InvalidDateRange when from is after to")
void shouldFailWhenFromAfterTo() {
var result = useCase.execute(null, null, null, null, to, from, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDateRange.class);
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
}
@Test
@DisplayName("should succeed when from equals to (same instant)")
void shouldSucceedWhenFromEqualsTo() {
when(repository.findAllByPerformedAtBetween(from, from))
.thenReturn(Result.success(List.of()));
var result = useCase.execute(null, null, null, null, from, from, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByPerformedAtBetween(from, from);
}
@Test
@DisplayName("should fail when repository fails for date range")
void shouldFailWhenRepositoryFailsForDateRange() {
when(repository.findAllByPerformedAtBetween(from, to))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = useCase.execute(null, null, null, null, from, to, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
}
}
@Nested
@DisplayName("Filter priority")
class FilterPriority {
@Test
@DisplayName("stockId takes priority over articleId and movementType")
@DisplayName("stockId takes priority over articleId, batchReference and movementType")
void stockIdTakesPriority() {
when(repository.findAllByStockId(StockId.of("stock-1")))
.thenReturn(Result.success(List.of()));
var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", actor);
var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByStockId(StockId.of("stock-1"));
verify(repository, never()).findAllByArticleId(any());
verify(repository, never()).findAllByBatchId(any());
verify(repository, never()).findAllByMovementType(any());
}
@Test
@DisplayName("articleId takes priority over movementType")
void articleIdTakesPriorityOverMovementType() {
@DisplayName("articleId takes priority over batchReference and movementType")
void articleIdTakesPriorityOverBatchAndMovementType() {
when(repository.findAllByArticleId(ArticleId.of("article-1")))
.thenReturn(Result.success(List.of()));
var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", actor);
var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByArticleId(ArticleId.of("article-1"));
verify(repository, never()).findAllByBatchId(any());
verify(repository, never()).findAllByMovementType(any());
}
@Test
@DisplayName("batchReference takes priority over movementType")
void batchReferenceTakesPriorityOverMovementType() {
when(repository.findAllByBatchId("CHARGE-001"))
.thenReturn(Result.success(List.of()));
var result = useCase.execute(null, null, "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByBatchId("CHARGE-001");
verify(repository, never()).findAllByMovementType(any());
}
@Test
@DisplayName("movementType takes priority over from/to")
void movementTypeTakesPriorityOverDateRange() {
when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT))
.thenReturn(Result.success(List.of()));
Instant from = Instant.parse("2026-01-01T00:00:00Z");
Instant to = Instant.parse("2026-12-31T23:59:59Z");
var result = useCase.execute(null, null, "GOODS_RECEIPT", null, from, to, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByMovementType(MovementType.GOODS_RECEIPT);
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
}
@Test
@DisplayName("stockId takes priority over from/to")
void stockIdTakesPriorityOverDateRange() {
when(repository.findAllByStockId(StockId.of("stock-1")))
.thenReturn(Result.success(List.of()));
Instant from = Instant.parse("2026-01-01T00:00:00Z");
Instant to = Instant.parse("2026-12-31T23:59:59Z");
var result = useCase.execute("stock-1", null, null, null, from, to, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByStockId(StockId.of("stock-1"));
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
}
@Test
@DisplayName("batchReference takes priority over from/to")
void batchReferenceTakesPriorityOverDateRange() {
when(repository.findAllByBatchId("CHARGE-001"))
.thenReturn(Result.success(List.of()));
Instant from = Instant.parse("2026-01-01T00:00:00Z");
Instant to = Instant.parse("2026-12-31T23:59:59Z");
var result = useCase.execute(null, null, null, "CHARGE-001", from, to, actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByBatchId("CHARGE-001");
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
}
}
@Nested
@ -216,7 +397,7 @@ class ListStockMovementsTest {
void shouldFailWhenUnauthorized() {
when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false);
var result = useCase.execute(null, null, null, actor);
var result = useCase.execute(null, null, null, null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class);

View file

@ -477,6 +477,110 @@ class StockMovementControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
@DisplayName("Nach batchReference filtern → 200")
void filterByBatchReference_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("batchReference", "CHARGE-001")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Nach Zeitraum (from + to) filtern → 200")
void filterByDateRange_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("from", "2020-01-01T00:00:00Z")
.param("to", "2030-12-31T23:59:59Z")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Nur from (open-ended) → 200")
void filterByFromOnly_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("from", "2020-01-01T00:00:00Z")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Nur to (open-ended) → 200")
void filterByToOnly_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("to", "2030-12-31T23:59:59Z")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("from nach to → 400")
void filterByInvalidDateRange_returns400() throws Exception {
mockMvc.perform(get("/api/inventory/stock-movements")
.param("from", "2030-01-01T00:00:00Z")
.param("to", "2020-01-01T00:00:00Z")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_DATE_RANGE"));
}
@Test
@DisplayName("stockId hat Priorität über batchReference")
void stockIdPriorityOverBatchReference_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("stockId", stockId)
.param("batchReference", "CHARGE-001")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@DisplayName("Unbekannte batchReference → 200 mit []")
void filterByUnknownBatchReference_returns200Empty() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("batchReference", "UNKNOWN-CHARGE")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
@DisplayName("Zeitraum in Zukunft → 200 mit []")
void filterByFutureDateRange_returns200Empty() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("from", "2099-01-01T00:00:00Z")
.param("to", "2099-12-31T23:59:59Z")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
}
// ==================== Einzelne Bewegung ====================