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

feat(inventory): Bestandsposition anlegen (#4)

Stock-Aggregate mit MinimumLevel, MinimumShelfLife und StockDraft.
Quantity/UnitOfMeasure nach shared.common verschoben für BC-übergreifende
Nutzung. REST-Endpoint POST /api/inventory/stocks mit Duplikat-Prüfung,
Validierung und Liquibase-Migration.
This commit is contained in:
Sebastian Frick 2026-02-19 21:33:29 +01:00
parent 7079f12475
commit 5219c93dd1
43 changed files with 1340 additions and 18 deletions

View file

@ -0,0 +1,262 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class StockTest {
// ==================== Create ====================
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create Stock with all fields")
void shouldCreateWithAllFields() {
var draft = new StockDraft(
"article-1", "location-1", "10.5", "KILOGRAM", 30);
var result = Stock.create(draft);
assertThat(result.isSuccess()).isTrue();
var stock = result.unsafeGetValue();
assertThat(stock.id()).isNotNull();
assertThat(stock.articleId().value()).isEqualTo("article-1");
assertThat(stock.storageLocationId().value()).isEqualTo("location-1");
assertThat(stock.minimumLevel()).isNotNull();
assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(stock.minimumShelfLife()).isNotNull();
assertThat(stock.minimumShelfLife().days()).isEqualTo(30);
}
@Test
@DisplayName("should create Stock with only required fields")
void shouldCreateWithOnlyRequiredFields() {
var draft = new StockDraft("article-1", "location-1", null, null, null);
var result = Stock.create(draft);
assertThat(result.isSuccess()).isTrue();
var stock = result.unsafeGetValue();
assertThat(stock.articleId().value()).isEqualTo("article-1");
assertThat(stock.storageLocationId().value()).isEqualTo("location-1");
assertThat(stock.minimumLevel()).isNull();
assertThat(stock.minimumShelfLife()).isNull();
}
@Test
@DisplayName("should fail when articleId is null")
void shouldFailWhenArticleIdNull() {
var draft = new StockDraft(null, "location-1", null, null, null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
}
@Test
@DisplayName("should fail when articleId is blank")
void shouldFailWhenArticleIdBlank() {
var draft = new StockDraft("", "location-1", null, null, null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
}
@Test
@DisplayName("should fail when storageLocationId is null")
void shouldFailWhenStorageLocationIdNull() {
var draft = new StockDraft("article-1", null, null, null, null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should fail when storageLocationId is blank")
void shouldFailWhenStorageLocationIdBlank() {
var draft = new StockDraft("article-1", "", null, null, null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidStorageLocationId.class);
}
@Test
@DisplayName("should fail when minimumLevel amount is negative")
void shouldFailWhenMinimumLevelNegative() {
var draft = new StockDraft("article-1", "location-1", "-1", "KILOGRAM", null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
}
@Test
@DisplayName("should fail when minimumLevel amount is not a number")
void shouldFailWhenMinimumLevelNotNumber() {
var draft = new StockDraft("article-1", "location-1", "abc", "KILOGRAM", null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
}
@Test
@DisplayName("should fail when minimumLevel unit is invalid")
void shouldFailWhenMinimumLevelUnitInvalid() {
var draft = new StockDraft("article-1", "location-1", "10", "INVALID_UNIT", null);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
}
@Test
@DisplayName("should accept minimumLevel amount of zero")
void shouldAcceptMinimumLevelZero() {
var draft = new StockDraft("article-1", "location-1", "0", "KILOGRAM", null);
var result = Stock.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().minimumLevel().quantity().amount())
.isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("should fail when minimumShelfLife is zero")
void shouldFailWhenMinimumShelfLifeZero() {
var draft = new StockDraft("article-1", "location-1", null, null, 0);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
}
@Test
@DisplayName("should fail when minimumShelfLife is negative")
void shouldFailWhenMinimumShelfLifeNegative() {
var draft = new StockDraft("article-1", "location-1", null, null, -5);
var result = Stock.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
}
@Test
@DisplayName("should accept minimumShelfLife of 1")
void shouldAcceptMinimumShelfLifeOne() {
var draft = new StockDraft("article-1", "location-1", null, null, 1);
var result = Stock.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().minimumShelfLife().days()).isEqualTo(1);
}
@Test
@DisplayName("should create with all UnitOfMeasure values")
void shouldCreateWithAllUnits() {
for (UnitOfMeasure unit : UnitOfMeasure.values()) {
var draft = new StockDraft("article-1", "location-1", "5", unit.name(), null);
var result = Stock.create(draft);
assertThat(result.isSuccess()).as("UnitOfMeasure %s should be valid", unit).isTrue();
}
}
}
// ==================== Reconstitute ====================
@Nested
@DisplayName("reconstitute()")
class Reconstitute {
@Test
@DisplayName("should reconstitute Stock from persistence")
void shouldReconstitute() {
var id = StockId.generate();
var articleId = ArticleId.of("article-1");
var locationId = StorageLocationId.of("location-1");
var quantity = Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null);
var minimumLevel = new MinimumLevel(quantity);
var minimumShelfLife = new MinimumShelfLife(30);
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife);
assertThat(stock.id()).isEqualTo(id);
assertThat(stock.articleId()).isEqualTo(articleId);
assertThat(stock.storageLocationId()).isEqualTo(locationId);
assertThat(stock.minimumLevel()).isEqualTo(minimumLevel);
assertThat(stock.minimumShelfLife()).isEqualTo(minimumShelfLife);
}
@Test
@DisplayName("should reconstitute Stock without optional fields")
void shouldReconstituteWithoutOptionals() {
var id = StockId.generate();
var articleId = ArticleId.of("article-1");
var locationId = StorageLocationId.of("location-1");
var stock = Stock.reconstitute(id, articleId, locationId, null, null);
assertThat(stock.minimumLevel()).isNull();
assertThat(stock.minimumShelfLife()).isNull();
}
}
// ==================== Equality ====================
@Nested
@DisplayName("equals / hashCode")
class Equality {
@Test
@DisplayName("should be equal if same ID")
void shouldBeEqualBySameId() {
var id = StockId.generate();
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null);
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null);
assertThat(stock1).isEqualTo(stock2);
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
}
@Test
@DisplayName("should not be equal if different ID")
void shouldNotBeEqualByDifferentId() {
var stock1 = createValidStock();
var stock2 = createValidStock();
assertThat(stock1).isNotEqualTo(stock2);
}
}
// ==================== Helpers ====================
private Stock createValidStock() {
var draft = new StockDraft("article-1", "location-1", "10", "KILOGRAM", 30);
return Stock.create(draft).unsafeGetValue();
}
}

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.QuantityError;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

View file

@ -1,5 +1,7 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

View file

@ -8,7 +8,10 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.math.BigDecimal;
import static de.effigenix.domain.production.UnitOfMeasure.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.QuantityError;
import static de.effigenix.shared.common.UnitOfMeasure.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

View file

@ -0,0 +1,220 @@
package de.effigenix.infrastructure.inventory.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.util.Set;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integrationstests für StockController.
*
* Abgedeckte Testfälle:
* - Story 2.1 Bestandsposition anlegen
*/
@DisplayName("Stock Controller Integration Tests")
class StockControllerIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
private String storageLocationId;
@BeforeEach
void setUp() throws Exception {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("stock.admin", "stock.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("stock.viewer", "stock.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "stock.admin", "STOCK_WRITE,STOCK_READ");
viewerToken = generateToken(viewer.getId(), "stock.viewer", "USER_READ");
storageLocationId = createStorageLocation();
}
// ==================== Bestandsposition anlegen Pflichtfelder ====================
@Test
@DisplayName("Bestandsposition mit Pflichtfeldern erstellen → 201")
void createStock_withRequiredFields_returns201() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.articleId").value(request.articleId()))
.andExpect(jsonPath("$.storageLocationId").value(storageLocationId))
.andExpect(jsonPath("$.minimumLevel").isEmpty())
.andExpect(jsonPath("$.minimumShelfLifeDays").isEmpty());
}
// ==================== Bestandsposition mit allen Feldern ====================
@Test
@DisplayName("Bestandsposition mit allen Feldern erstellen → 201")
void createStock_withAllFields_returns201() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, "10.5", "KILOGRAM", 30);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.articleId").value(request.articleId()))
.andExpect(jsonPath("$.storageLocationId").value(storageLocationId))
.andExpect(jsonPath("$.minimumLevel.amount").value(10.5))
.andExpect(jsonPath("$.minimumLevel.unit").value("KILOGRAM"))
.andExpect(jsonPath("$.minimumShelfLifeDays").value(30));
}
// ==================== Duplikat ====================
@Test
@DisplayName("Bestandsposition Duplikat (gleiche articleId+storageLocationId) → 409")
void createStock_duplicate_returns409() throws Exception {
String articleId = UUID.randomUUID().toString();
var request = new CreateStockRequest(articleId, storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_STOCK"));
}
// ==================== Validierungsfehler ====================
@Test
@DisplayName("Bestandsposition ohne articleId → 400")
void createStock_withBlankArticleId_returns400() throws Exception {
var request = new CreateStockRequest("", storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Bestandsposition ohne storageLocationId → 400")
void createStock_withBlankStorageLocationId_returns400() throws Exception {
var request = new CreateStockRequest(UUID.randomUUID().toString(), "", null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Bestandsposition mit ungültigem MinimumLevel → 400")
void createStock_withInvalidMinimumLevel_returns400() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, "-1", "KILOGRAM", null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_MINIMUM_LEVEL"));
}
@Test
@DisplayName("Bestandsposition mit ungültiger Unit → 400")
void createStock_withInvalidUnit_returns400() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, "10", "INVALID_UNIT", null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_MINIMUM_LEVEL"));
}
@Test
@DisplayName("Bestandsposition mit ungültigem MinimumShelfLife → 400")
void createStock_withInvalidMinimumShelfLife_returns400() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, 0);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_MINIMUM_SHELF_LIFE"));
}
// ==================== Autorisierung ====================
@Test
@DisplayName("Bestandsposition erstellen ohne STOCK_WRITE → 403")
void createStock_withViewerToken_returns403() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Bestandsposition erstellen ohne Token → 401")
void createStock_withoutToken_returns401() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
// ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception {
String json = """
{"name": "Testlager-%s", "storageType": "DRY_STORAGE"}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
}

View file

@ -1,13 +1,12 @@
package de.effigenix.domain.production;
package de.effigenix.shared.common;
import de.effigenix.shared.common.Result;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static de.effigenix.domain.production.UnitOfMeasure.*;
import static de.effigenix.shared.common.UnitOfMeasure.*;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Quantity Value Object")