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

feat(inventory): StorageLocation Aggregate implementieren (#1)

Vertikaler Slice für Story 1.1 – Lagerort anlegen:

Domain: StorageLocation Aggregate mit VOs (StorageLocationId, StorageLocationName,
StorageType, TemperatureRange), StorageLocationError (sealed interface),
Draft-Records und Repository Interface.

Application: CreateStorageLocation UseCase mit Uniqueness-Check.

Infrastructure: JPA Entity, Mapper, Repository, REST Controller
(POST /api/inventory/storage-locations), Liquibase Migration (009),
InventoryErrorHttpStatusMapper, InventoryUseCaseConfiguration.

Tests: 28 Domain-Unit-Tests, 11 Integration-Tests.

Closes #1
This commit is contained in:
Sebastian Frick 2026-02-19 09:51:48 +01:00
parent 554185a012
commit c474388f32
25 changed files with 1527 additions and 0 deletions

View file

@ -0,0 +1,371 @@
package de.effigenix.domain.inventory;
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 static org.assertj.core.api.Assertions.assertThat;
class StorageLocationTest {
// ==================== Create ====================
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create StorageLocation with valid data")
void shouldCreateWithValidData() {
var draft = new StorageLocationDraft("Kühlraum 1", "COLD_ROOM", "-2", "8");
var result = StorageLocation.create(draft);
assertThat(result.isSuccess()).isTrue();
var location = result.unsafeGetValue();
assertThat(location.id()).isNotNull();
assertThat(location.name().value()).isEqualTo("Kühlraum 1");
assertThat(location.storageType()).isEqualTo(StorageType.COLD_ROOM);
assertThat(location.temperatureRange()).isNotNull();
assertThat(location.temperatureRange().minTemperature().intValue()).isEqualTo(-2);
assertThat(location.temperatureRange().maxTemperature().intValue()).isEqualTo(8);
assertThat(location.active()).isTrue();
}
@Test
@DisplayName("should create StorageLocation without temperature range")
void shouldCreateWithoutTemperatureRange() {
var draft = new StorageLocationDraft("Trockenlager", "DRY_STORAGE", null, null);
var result = StorageLocation.create(draft);
assertThat(result.isSuccess()).isTrue();
var location = result.unsafeGetValue();
assertThat(location.temperatureRange()).isNull();
assertThat(location.isTemperatureControlled()).isFalse();
}
@Test
@DisplayName("should fail when name is blank")
void shouldFailWhenNameBlank() {
var draft = new StorageLocationDraft("", "COLD_ROOM", null, null);
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidName.class);
}
@Test
@DisplayName("should fail when name is null")
void shouldFailWhenNameNull() {
var draft = new StorageLocationDraft(null, "COLD_ROOM", null, null);
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidName.class);
}
@Test
@DisplayName("should fail when name exceeds 100 chars")
void shouldFailWhenNameTooLong() {
var longName = "A".repeat(101);
var draft = new StorageLocationDraft(longName, "COLD_ROOM", null, null);
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidName.class);
}
@Test
@DisplayName("should accept name with exactly 100 chars")
void shouldAcceptNameWith100Chars() {
var name = "A".repeat(100);
var draft = new StorageLocationDraft(name, "DRY_STORAGE", null, null);
var result = StorageLocation.create(draft);
assertThat(result.isSuccess()).isTrue();
}
@Test
@DisplayName("should fail when storageType is invalid")
void shouldFailWhenStorageTypeInvalid() {
var draft = new StorageLocationDraft("Test", "INVALID_TYPE", null, null);
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidStorageType.class);
}
@Test
@DisplayName("should fail when storageType is null")
void shouldFailWhenStorageTypeNull() {
var draft = new StorageLocationDraft("Test", null, null, null);
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidStorageType.class);
}
@Test
@DisplayName("should fail when min >= max temperature")
void shouldFailWhenMinGreaterThanMax() {
var draft = new StorageLocationDraft("Test", "COLD_ROOM", "10", "5");
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidTemperatureRange.class);
}
@Test
@DisplayName("should fail when min == max temperature")
void shouldFailWhenMinEqualsMax() {
var draft = new StorageLocationDraft("Test", "COLD_ROOM", "5", "5");
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidTemperatureRange.class);
}
@Test
@DisplayName("should fail when temperature below -50")
void shouldFailWhenTemperatureBelowMinus50() {
var draft = new StorageLocationDraft("Test", "FREEZER", "-51", "-20");
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidTemperatureRange.class);
}
@Test
@DisplayName("should fail when temperature above 80")
void shouldFailWhenTemperatureAbove80() {
var draft = new StorageLocationDraft("Test", "DRY_STORAGE", "20", "81");
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidTemperatureRange.class);
}
@Test
@DisplayName("should accept boundary temperatures -50 and +80")
void shouldAcceptBoundaryTemperatures() {
var draft = new StorageLocationDraft("Test", "DRY_STORAGE", "-50", "80");
var result = StorageLocation.create(draft);
assertThat(result.isSuccess()).isTrue();
}
@Test
@DisplayName("should fail when temperature is not a number")
void shouldFailWhenTemperatureNotNumber() {
var draft = new StorageLocationDraft("Test", "COLD_ROOM", "abc", "10");
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidTemperatureRange.class);
}
@Test
@DisplayName("should fail when only min temperature provided")
void shouldFailWhenOnlyMinTemperature() {
var draft = new StorageLocationDraft("Test", "COLD_ROOM", "5", null);
var result = StorageLocation.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidTemperatureRange.class);
}
@Test
@DisplayName("should create all storage types")
void shouldCreateAllStorageTypes() {
for (StorageType type : StorageType.values()) {
var draft = new StorageLocationDraft("Test " + type, type.name(), null, null);
var result = StorageLocation.create(draft);
assertThat(result.isSuccess()).as("StorageType %s should be valid", type).isTrue();
}
}
}
// ==================== Update ====================
@Nested
@DisplayName("update()")
class Update {
@Test
@DisplayName("should update name")
void shouldUpdateName() {
var location = createValidLocation();
var updateDraft = new StorageLocationUpdateDraft("Neuer Name", null, null);
var result = location.update(updateDraft);
assertThat(result.isSuccess()).isTrue();
assertThat(location.name().value()).isEqualTo("Neuer Name");
}
@Test
@DisplayName("should update temperature range")
void shouldUpdateTemperatureRange() {
var location = createValidLocation();
var updateDraft = new StorageLocationUpdateDraft(null, "-5", "12");
var result = location.update(updateDraft);
assertThat(result.isSuccess()).isTrue();
assertThat(location.temperatureRange().minTemperature().intValue()).isEqualTo(-5);
assertThat(location.temperatureRange().maxTemperature().intValue()).isEqualTo(12);
}
@Test
@DisplayName("should fail update when name is blank")
void shouldFailUpdateWhenNameBlank() {
var location = createValidLocation();
var updateDraft = new StorageLocationUpdateDraft("", null, null);
var result = location.update(updateDraft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidName.class);
}
@Test
@DisplayName("should not change name when null in draft")
void shouldNotChangeNameWhenNull() {
var location = createValidLocation();
var originalName = location.name().value();
var updateDraft = new StorageLocationUpdateDraft(null, null, null);
var result = location.update(updateDraft);
assertThat(result.isSuccess()).isTrue();
assertThat(location.name().value()).isEqualTo(originalName);
}
}
// ==================== Deactivate / Activate ====================
@Nested
@DisplayName("deactivate() / activate()")
class DeactivateActivate {
@Test
@DisplayName("should deactivate active location")
void shouldDeactivate() {
var location = createValidLocation();
var result = location.deactivate();
assertThat(result.isSuccess()).isTrue();
assertThat(location.active()).isFalse();
}
@Test
@DisplayName("should fail deactivating already inactive location")
void shouldFailDeactivatingInactive() {
var location = createValidLocation();
location.deactivate();
var result = location.deactivate();
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.AlreadyInactive.class);
}
@Test
@DisplayName("should activate inactive location")
void shouldActivate() {
var location = createValidLocation();
location.deactivate();
var result = location.activate();
assertThat(result.isSuccess()).isTrue();
assertThat(location.active()).isTrue();
}
@Test
@DisplayName("should fail activating already active location")
void shouldFailActivatingActive() {
var location = createValidLocation();
var result = location.activate();
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.AlreadyActive.class);
}
}
// ==================== Query Methods ====================
@Nested
@DisplayName("query methods")
class QueryMethods {
@Test
@DisplayName("isTemperatureControlled should return true when range is set")
void shouldReturnTrueWhenRangeSet() {
var draft = new StorageLocationDraft("Kühlraum", "COLD_ROOM", "-2", "8");
var location = StorageLocation.create(draft).unsafeGetValue();
assertThat(location.isTemperatureControlled()).isTrue();
}
@Test
@DisplayName("isTemperatureControlled should return false when range is null")
void shouldReturnFalseWhenRangeNull() {
var draft = new StorageLocationDraft("Trockenlager", "DRY_STORAGE", null, null);
var location = StorageLocation.create(draft).unsafeGetValue();
assertThat(location.isTemperatureControlled()).isFalse();
}
}
// ==================== Equality ====================
@Nested
@DisplayName("equals / hashCode")
class Equality {
@Test
@DisplayName("should be equal if same ID")
void shouldBeEqualBySameId() {
var id = StorageLocationId.generate();
var loc1 = StorageLocation.reconstitute(id, new StorageLocationName("A"), StorageType.COLD_ROOM, null, true);
var loc2 = StorageLocation.reconstitute(id, new StorageLocationName("B"), StorageType.DRY_STORAGE, null, false);
assertThat(loc1).isEqualTo(loc2);
assertThat(loc1.hashCode()).isEqualTo(loc2.hashCode());
}
@Test
@DisplayName("should not be equal if different ID")
void shouldNotBeEqualByDifferentId() {
var loc1 = createValidLocation();
var loc2 = createValidLocation();
assertThat(loc1).isNotEqualTo(loc2);
}
}
// ==================== Helpers ====================
private StorageLocation createValidLocation() {
var draft = new StorageLocationDraft("Kühlraum 1", "COLD_ROOM", "-2", "8");
return StorageLocation.create(draft).unsafeGetValue();
}
}

View file

@ -0,0 +1,291 @@
package de.effigenix.infrastructure.inventory.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.domain.usermanagement.UserStatus;
import de.effigenix.infrastructure.inventory.web.dto.CreateStorageLocationRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository;
import de.effigenix.infrastructure.usermanagement.persistence.repository.UserJpaRepository;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.Set;
import java.util.UUID;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integrationstests für StorageLocationController.
*
* Abgedeckte Testfälle: Story 1.1 Lagerort anlegen
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
@DisplayName("StorageLocation Controller Integration Tests")
class StorageLocationControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserJpaRepository userRepository;
@Autowired
private RoleJpaRepository roleRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpiration;
private String adminToken;
private String viewerToken;
@BeforeEach
void setUp() {
RoleEntity adminRole = new RoleEntity(
UUID.randomUUID().toString(), RoleName.ADMIN, Set.of(), "Admin");
roleRepository.save(adminRole);
RoleEntity viewerRole = new RoleEntity(
UUID.randomUUID().toString(), RoleName.PRODUCTION_WORKER, Set.of(), "Viewer");
roleRepository.save(viewerRole);
String adminId = UUID.randomUUID().toString();
userRepository.save(new UserEntity(
adminId, "inv.admin", "inv.admin@test.com",
passwordEncoder.encode("Pass123"), Set.of(adminRole),
"BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null));
String viewerId = UUID.randomUUID().toString();
userRepository.save(new UserEntity(
viewerId, "inv.viewer", "inv.viewer@test.com",
passwordEncoder.encode("Pass123"), Set.of(viewerRole),
"BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null));
adminToken = generateToken(adminId, "inv.admin", "STOCK_WRITE");
viewerToken = generateToken(viewerId, "inv.viewer", "USER_READ");
}
// ==================== Lagerort anlegen Pflichtfelder ====================
@Test
@DisplayName("Lagerort mit Pflichtfeldern erstellen → 201")
void createStorageLocation_withRequiredFields_returns201() throws Exception {
var request = new CreateStorageLocationRequest(
"Trockenlager 1", "DRY_STORAGE", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.name").value("Trockenlager 1"))
.andExpect(jsonPath("$.storageType").value("DRY_STORAGE"))
.andExpect(jsonPath("$.temperatureRange").isEmpty())
.andExpect(jsonPath("$.active").value(true));
}
// ==================== Lagerort mit Temperaturbereich ====================
@Test
@DisplayName("Lagerort mit Temperaturbereich erstellen → 201")
void createStorageLocation_withTemperatureRange_returns201() throws Exception {
var request = new CreateStorageLocationRequest(
"Kühlraum 1", "COLD_ROOM", "-2", "8");
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Kühlraum 1"))
.andExpect(jsonPath("$.storageType").value("COLD_ROOM"))
.andExpect(jsonPath("$.temperatureRange.minTemperature").value(-2))
.andExpect(jsonPath("$.temperatureRange.maxTemperature").value(8))
.andExpect(jsonPath("$.active").value(true));
}
// ==================== Validierungsfehler ====================
@Test
@DisplayName("Lagerort ohne Namen → 400")
void createStorageLocation_withBlankName_returns400() throws Exception {
var request = new CreateStorageLocationRequest(
"", "COLD_ROOM", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Lagerort ohne StorageType → 400")
void createStorageLocation_withBlankStorageType_returns400() throws Exception {
var request = new CreateStorageLocationRequest(
"Test", "", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Lagerort mit ungültigem StorageType → 400")
void createStorageLocation_withInvalidStorageType_returns400() throws Exception {
var request = new CreateStorageLocationRequest(
"Test", "INVALID_TYPE", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_STORAGE_TYPE"));
}
@Test
@DisplayName("Lagerort mit min >= max Temperatur → 400")
void createStorageLocation_withInvalidTemperatureRange_returns400() throws Exception {
var request = new CreateStorageLocationRequest(
"Test", "COLD_ROOM", "10", "5");
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TEMPERATURE_RANGE"));
}
@Test
@DisplayName("Lagerort mit Temperatur außerhalb des Bereichs → 400")
void createStorageLocation_withTemperatureOutOfRange_returns400() throws Exception {
var request = new CreateStorageLocationRequest(
"Test", "FREEZER", "-51", "-20");
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TEMPERATURE_RANGE"));
}
// ==================== Duplikat-Name ====================
@Test
@DisplayName("Lagerort mit doppeltem Namen → 409")
void createStorageLocation_withDuplicateName_returns409() throws Exception {
var request = new CreateStorageLocationRequest(
"Kühlraum Unique", "COLD_ROOM", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NAME_EXISTS"));
}
// ==================== Autorisierung ====================
@Test
@DisplayName("Lagerort erstellen ohne STOCK_WRITE → 403")
void createStorageLocation_withViewerToken_returns403() throws Exception {
var request = new CreateStorageLocationRequest(
"Kein Zugriff", "DRY_STORAGE", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Lagerort erstellen ohne Token → 401")
void createStorageLocation_withoutToken_returns401() throws Exception {
var request = new CreateStorageLocationRequest(
"Kein Token", "DRY_STORAGE", null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
// ==================== Alle StorageTypes ====================
@Test
@DisplayName("Alle StorageTypes erstellen → jeweils 201")
void createStorageLocation_allTypes_returns201() throws Exception {
String[] types = {"COLD_ROOM", "FREEZER", "DRY_STORAGE", "DISPLAY_COUNTER", "PRODUCTION_AREA"};
for (String type : types) {
var request = new CreateStorageLocationRequest(
"Lager " + type, type, null, null);
mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.storageType").value(type));
}
}
// ==================== Hilfsmethoden ====================
private String generateToken(String userId, String username, String permissions) {
long now = System.currentTimeMillis();
javax.crypto.SecretKey key = io.jsonwebtoken.security.Keys.hmacShaKeyFor(
jwtSecret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.subject(userId)
.claim("username", username)
.claim("permissions", permissions)
.issuedAt(new Date(now))
.expiration(new Date(now + jwtExpiration))
.signWith(key)
.compact();
}
}