1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 18:49:59 +01:00

feat(inventory): GET-Endpoints für Bestandsposition und Chargen abfragen

Query-Endpoints für Stock-Aggregate: GET /api/inventory/stocks (mit
optionalen Filtern storageLocationId/articleId) und GET /api/inventory/stocks/{id}.
StockResponse enthält nun Batches, totalQuantity, availableQuantity und
quantityUnit. Abgesichert über @PreAuthorize STOCK_READ.
This commit is contained in:
Sebastian Frick 2026-02-20 08:45:20 +01:00
parent e0ac2c2f41
commit 1ef37497c3
12 changed files with 685 additions and 4 deletions

View file

@ -0,0 +1,94 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
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.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("GetStock Use Case")
class GetStockTest {
@Mock private StockRepository stockRepository;
private GetStock getStock;
private Stock existingStock;
@BeforeEach
void setUp() {
getStock = new GetStock(stockRepository);
existingStock = Stock.reconstitute(
StockId.of("stock-1"),
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null, List.of()
);
}
@Test
@DisplayName("should return stock when found")
void shouldReturnStockWhenFound() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(existingStock)));
var result = getStock.execute("stock-1");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().id().value()).isEqualTo("stock-1");
}
@Test
@DisplayName("should fail with StockNotFound when stock does not exist")
void shouldFailWhenStockNotFound() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.empty()));
var result = getStock.execute("stock-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails")
void shouldFailWhenRepositoryFails() {
when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = getStock.execute("stock-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with StockNotFound when id is null")
void shouldFailWhenIdIsNull() {
var result = getStock.execute(null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
}
@Test
@DisplayName("should fail with StockNotFound when id is blank")
void shouldFailWhenIdIsBlank() {
var result = getStock.execute(" ");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
}
}

View file

@ -0,0 +1,145 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
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.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("ListStocks Use Case")
class ListStocksTest {
@Mock private StockRepository stockRepository;
private ListStocks listStocks;
private Stock stock1;
private Stock stock2;
@BeforeEach
void setUp() {
listStocks = new ListStocks(stockRepository);
stock1 = Stock.reconstitute(
StockId.of("stock-1"),
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null, List.of()
);
stock2 = Stock.reconstitute(
StockId.of("stock-2"),
ArticleId.of("article-2"),
StorageLocationId.of("location-1"),
null, null, List.of()
);
}
@Test
@DisplayName("should return all stocks when no filter provided")
void shouldReturnAllStocksWhenNoFilter() {
when(stockRepository.findAll()).thenReturn(Result.success(List.of(stock1, stock2)));
var result = listStocks.execute(null, null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
verify(stockRepository).findAll();
}
@Test
@DisplayName("should filter by storageLocationId")
void shouldFilterByStorageLocationId() {
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(stock1, stock2)));
var result = listStocks.execute("location-1", null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
verify(stockRepository).findAllByStorageLocationId(StorageLocationId.of("location-1"));
verify(stockRepository, never()).findAll();
}
@Test
@DisplayName("should filter by articleId")
void shouldFilterByArticleId() {
when(stockRepository.findAllByArticleId(ArticleId.of("article-1")))
.thenReturn(Result.success(List.of(stock1)));
var result = listStocks.execute(null, "article-1");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(stockRepository).findAllByArticleId(ArticleId.of("article-1"));
verify(stockRepository, never()).findAll();
}
@Test
@DisplayName("should fail when both filters are provided")
void shouldFailWhenBothFiltersProvided() {
var result = listStocks.execute("location-1", "article-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
verifyNoInteractions(stockRepository);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails for findAll")
void shouldFailWhenRepositoryFailsForFindAll() {
when(stockRepository.findAll())
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listStocks.execute(null, null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails for storageLocationId filter")
void shouldFailWhenRepositoryFailsForStorageLocationFilter() {
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listStocks.execute("location-1", null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails for articleId filter")
void shouldFailWhenRepositoryFailsForArticleIdFilter() {
when(stockRepository.findAllByArticleId(ArticleId.of("article-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listStocks.execute(null, "article-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should return empty list when no stocks match")
void shouldReturnEmptyListWhenNoStocksMatch() {
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("unknown")))
.thenReturn(Result.success(List.of()));
var result = listStocks.execute("unknown", null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
}

View file

@ -15,6 +15,7 @@ import org.springframework.http.MediaType;
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.*;
@ -24,6 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Abgedeckte Testfälle:
* - Story 2.1 Bestandsposition anlegen
* - Story 2.2 Charge einbuchen (addBatch)
* - Story 2.5 Bestandsposition und Chargen abfragen
*/
@DisplayName("Stock Controller Integration Tests")
class StockControllerIntegrationTest extends AbstractIntegrationTest {
@ -548,6 +550,183 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
}
}
// ==================== Bestandsposition abfragen (getStock) ====================
@Nested
@DisplayName("GET /{id} Bestandsposition abfragen")
class GetStockById {
@Test
@DisplayName("Bestandsposition abfragen → 200 mit Batches und Quantities")
void getStock_returns200WithBatchesAndQuantities() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(stockId))
.andExpect(jsonPath("$.batches").isArray())
.andExpect(jsonPath("$.batches.length()").value(1))
.andExpect(jsonPath("$.batches[0].batchId").isNotEmpty())
.andExpect(jsonPath("$.batches[0].status").value("AVAILABLE"))
.andExpect(jsonPath("$.totalQuantity").value(10))
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.availableQuantity").value(10));
}
@Test
@DisplayName("Bestandsposition ohne Chargen abfragen → 200 mit leeren Batches")
void getStock_noBatches_returns200() throws Exception {
String stockId = createStock();
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(stockId))
.andExpect(jsonPath("$.batches").isArray())
.andExpect(jsonPath("$.batches.length()").value(0))
.andExpect(jsonPath("$.totalQuantity").value(0))
.andExpect(jsonPath("$.quantityUnit").isEmpty())
.andExpect(jsonPath("$.availableQuantity").value(0));
}
@Test
@DisplayName("Bestandsposition mit gesperrter Charge → availableQuantity exkludiert BLOCKED")
void getStock_withBlockedBatch_availableExcludesBlocked() throws Exception {
String stockId = createStock();
String batchId = addBatchToStock(stockId);
// Charge sperren
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reason": "Quality issue"}
"""))
.andExpect(status().isOk());
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalQuantity").value(10))
.andExpect(jsonPath("$.availableQuantity").value(0));
}
@Test
@DisplayName("Nicht existierende Bestandsposition → 404")
void getStock_notFound_returns404() throws Exception {
mockMvc.perform(get("/api/inventory/stocks/{id}", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
}
@Test
@DisplayName("Bestandsposition abfragen ohne STOCK_READ → 403")
void getStock_withViewerToken_returns403() throws Exception {
String stockId = createStock();
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Bestandsposition abfragen ohne Token → 401")
void getStock_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/inventory/stocks/{id}", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
}
// ==================== Bestandspositionen auflisten (listStocks) ====================
@Nested
@DisplayName("GET / Bestandspositionen auflisten")
class ListStocksEndpoint {
@Test
@DisplayName("Alle Bestandspositionen auflisten → 200")
void listStocks_returnsAll() throws Exception {
createStock();
String storageLocationId2 = createStorageLocation();
createStockForLocation(storageLocationId2);
mockMvc.perform(get("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2));
}
@Test
@DisplayName("Bestandspositionen nach storageLocationId filtern → 200")
void listStocks_filterByStorageLocationId() throws Exception {
createStock();
String storageLocationId2 = createStorageLocation();
createStockForLocation(storageLocationId2);
mockMvc.perform(get("/api/inventory/stocks")
.param("storageLocationId", storageLocationId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].storageLocationId").value(storageLocationId));
}
@Test
@DisplayName("Bestandspositionen nach articleId filtern → 200")
void listStocks_filterByArticleId() throws Exception {
String articleId = UUID.randomUUID().toString();
createStockForArticle(articleId);
mockMvc.perform(get("/api/inventory/stocks")
.param("articleId", articleId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].articleId").value(articleId));
}
@Test
@DisplayName("Leere Liste wenn keine Stocks vorhanden → 200")
void listStocks_empty_returns200() throws Exception {
mockMvc.perform(get("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
@DisplayName("Bestandspositionen mit Batches enthalten berechnete Quantities")
void listStocks_withBatches_containsQuantities() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
mockMvc.perform(get("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].batches.length()").value(1))
.andExpect(jsonPath("$[0].totalQuantity").value(10))
.andExpect(jsonPath("$[0].availableQuantity").value(10));
}
@Test
@DisplayName("Bestandspositionen auflisten ohne STOCK_READ → 403")
void listStocks_withViewerToken_returns403() throws Exception {
mockMvc.perform(get("/api/inventory/stocks")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Bestandspositionen auflisten ohne Token → 401")
void listStocks_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/inventory/stocks"))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception {
@ -593,4 +772,32 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createStockForLocation(String locationId) throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), locationId, null, null, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createStockForArticle(String articleId) throws Exception {
var request = new CreateStockRequest(
articleId, storageLocationId, null, null, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
}