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:
parent
fa6c0c2d70
commit
0e5d8f7025
13 changed files with 416 additions and 26 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ====================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue