diff --git a/backend/src/main/java/de/effigenix/application/inventory/CreateStorageLocation.java b/backend/src/main/java/de/effigenix/application/inventory/CreateStorageLocation.java new file mode 100644 index 0000000..842a47b --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/CreateStorageLocation.java @@ -0,0 +1,51 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.CreateStorageLocationCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class CreateStorageLocation { + + private final StorageLocationRepository storageLocationRepository; + + public CreateStorageLocation(StorageLocationRepository storageLocationRepository) { + this.storageLocationRepository = storageLocationRepository; + } + + public Result execute(CreateStorageLocationCommand cmd, ActorId performedBy) { + // 1. Draft aus Command bauen (kein VO-Wissen im Use Case) + var draft = new StorageLocationDraft( + cmd.name(), cmd.storageType(), cmd.minTemperature(), cmd.maxTemperature() + ); + + // 2. Aggregate erzeugen (validiert intern) + StorageLocation location; + switch (StorageLocation.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> location = val; + } + + // 3. Uniqueness-Check (Application-Concern: braucht Repository) + switch (storageLocationRepository.existsByName(location.name())) { + case Result.Failure(var err) -> + { return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); } + case Result.Success(var exists) -> { + if (exists) { + return Result.failure(new StorageLocationError.NameAlreadyExists(cmd.name())); + } + } + } + + // 4. Speichern + switch (storageLocationRepository.save(location)) { + case Result.Failure(var err) -> + { return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(location); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/CreateStorageLocationCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/CreateStorageLocationCommand.java new file mode 100644 index 0000000..995fac8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/CreateStorageLocationCommand.java @@ -0,0 +1,8 @@ +package de.effigenix.application.inventory.command; + +public record CreateStorageLocationCommand( + String name, + String storageType, + String minTemperature, + String maxTemperature +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocation.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocation.java new file mode 100644 index 0000000..0d4ab1c --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocation.java @@ -0,0 +1,178 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; +import java.util.Objects; + +import static de.effigenix.shared.common.Result.*; + +/** + * StorageLocation aggregate root. + * + * Invariants: + * - Name must not be blank, max 100 chars + * - Name must be unique (Application Layer, Repository-Concern) + * - StorageType is immutable after creation + * - TemperatureRange: minTemperature < maxTemperature, values in range -50°C to +80°C + * - Cannot deactivate if Stock exists at this location (Application Layer) + */ +public class StorageLocation { + + private final StorageLocationId id; + private StorageLocationName name; + private final StorageType storageType; + private TemperatureRange temperatureRange; + private boolean active; + + private StorageLocation( + StorageLocationId id, + StorageLocationName name, + StorageType storageType, + TemperatureRange temperatureRange, + boolean active + ) { + this.id = id; + this.name = name; + this.storageType = storageType; + this.temperatureRange = temperatureRange; + this.active = active; + } + + /** + * Factory: Erzeugt eine neue StorageLocation aus rohen Eingaben. + * Orchestriert Validierung aller VOs intern. + */ + public static Result create(StorageLocationDraft draft) { + // 1. Name validieren (Pflicht) + StorageLocationName name; + switch (StorageLocationName.of(draft.name())) { + case Failure(var err) -> { return Result.failure(err); } + case Success(var val) -> name = val; + } + + // 2. StorageType validieren (Pflicht) + StorageType storageType; + try { + storageType = StorageType.valueOf(draft.storageType()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StorageLocationError.InvalidStorageType( + draft.storageType() == null ? "null" : draft.storageType())); + } + + // 3. TemperatureRange optional + TemperatureRange temperatureRange = null; + if (draft.minTemperature() != null || draft.maxTemperature() != null) { + BigDecimal min; + BigDecimal max; + try { + min = draft.minTemperature() != null ? new BigDecimal(draft.minTemperature()) : null; + max = draft.maxTemperature() != null ? new BigDecimal(draft.maxTemperature()) : null; + } catch (NumberFormatException e) { + return Result.failure(new StorageLocationError.InvalidTemperatureRange( + "Temperature values must be valid numbers")); + } + switch (TemperatureRange.of(min, max)) { + case Failure(var err) -> { return Result.failure(err); } + case Success(var val) -> temperatureRange = val; + } + } + + return Result.success(new StorageLocation( + StorageLocationId.generate(), name, storageType, temperatureRange, true + )); + } + + /** + * Reconstitute from persistence without re-validation. + */ + public static StorageLocation reconstitute( + StorageLocationId id, + StorageLocationName name, + StorageType storageType, + TemperatureRange temperatureRange, + boolean active + ) { + return new StorageLocation(id, name, storageType, temperatureRange, active); + } + + // ==================== Business Methods ==================== + + /** + * Wendet partielle Updates an. StorageType ist immutable. + * null-Felder im Draft werden ignoriert. + */ + public Result update(StorageLocationUpdateDraft draft) { + if (draft.name() != null) { + switch (StorageLocationName.of(draft.name())) { + case Failure(var err) -> { return Result.failure(err); } + case Success(var val) -> this.name = val; + } + } + + if (draft.minTemperature() != null || draft.maxTemperature() != null) { + BigDecimal min; + BigDecimal max; + try { + min = draft.minTemperature() != null ? new BigDecimal(draft.minTemperature()) : null; + max = draft.maxTemperature() != null ? new BigDecimal(draft.maxTemperature()) : null; + } catch (NumberFormatException e) { + return Result.failure(new StorageLocationError.InvalidTemperatureRange( + "Temperature values must be valid numbers")); + } + switch (TemperatureRange.of(min, max)) { + case Failure(var err) -> { return Result.failure(err); } + case Success(var val) -> this.temperatureRange = val; + } + } + + return Result.success(null); + } + + public Result deactivate() { + if (!this.active) { + return Result.failure(new StorageLocationError.AlreadyInactive()); + } + this.active = false; + return Result.success(null); + } + + public Result activate() { + if (this.active) { + return Result.failure(new StorageLocationError.AlreadyActive()); + } + this.active = true; + return Result.success(null); + } + + // ==================== Query Methods ==================== + + public boolean isTemperatureControlled() { + return temperatureRange != null; + } + + // ==================== Getters ==================== + + public StorageLocationId id() { return id; } + public StorageLocationName name() { return name; } + public StorageType storageType() { return storageType; } + public TemperatureRange temperatureRange() { return temperatureRange; } + public boolean active() { return active; } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof StorageLocation other)) return false; + return Objects.equals(id, other.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "StorageLocation{id=" + id + ", name=" + name + ", type=" + storageType + ", active=" + active + "}"; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationDraft.java new file mode 100644 index 0000000..ff15b98 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationDraft.java @@ -0,0 +1,18 @@ +package de.effigenix.domain.inventory; + +/** + * Rohe Eingabe zum Erzeugen einer StorageLocation. + * Wird vom Application Layer aus dem Command gebaut und an StorageLocation.create() übergeben. + * Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion. + * + * @param name Pflicht + * @param storageType Pflicht (StorageType enum als String) + * @param minTemperature Optional – BigDecimal als String, nullable + * @param maxTemperature Optional – BigDecimal als String, nullable + */ +public record StorageLocationDraft( + String name, + String storageType, + String minTemperature, + String maxTemperature +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationError.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationError.java new file mode 100644 index 0000000..957d862 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationError.java @@ -0,0 +1,55 @@ +package de.effigenix.domain.inventory; + +public sealed interface StorageLocationError { + + String code(); + String message(); + + record NameAlreadyExists(String name) implements StorageLocationError { + @Override public String code() { return "STORAGE_LOCATION_NAME_EXISTS"; } + @Override public String message() { return "Storage location name already exists: " + name; } + } + + record InvalidName(String reason) implements StorageLocationError { + @Override public String code() { return "INVALID_STORAGE_LOCATION_NAME"; } + @Override public String message() { return "Invalid storage location name: " + reason; } + } + + record InvalidTemperatureRange(String reason) implements StorageLocationError { + @Override public String code() { return "INVALID_TEMPERATURE_RANGE"; } + @Override public String message() { return "Invalid temperature range: " + reason; } + } + + record InvalidStorageType(String value) implements StorageLocationError { + @Override public String code() { return "INVALID_STORAGE_TYPE"; } + @Override public String message() { return "Invalid storage type: " + value; } + } + + record StorageLocationNotFound(String id) implements StorageLocationError { + @Override public String code() { return "STORAGE_LOCATION_NOT_FOUND"; } + @Override public String message() { return "Storage location not found: " + id; } + } + + record StockExistsAtLocation(String locationId) implements StorageLocationError { + @Override public String code() { return "STOCK_EXISTS_AT_LOCATION"; } + @Override public String message() { return "Cannot deactivate: stock exists at location " + locationId; } + } + + record AlreadyActive() implements StorageLocationError { + @Override public String code() { return "ALREADY_ACTIVE"; } + @Override public String message() { return "Storage location is already active"; } + } + + record AlreadyInactive() implements StorageLocationError { + @Override public String code() { return "ALREADY_INACTIVE"; } + @Override public String message() { return "Storage location is already inactive"; } + } + + record Unauthorized(String message) implements StorageLocationError { + @Override public String code() { return "UNAUTHORIZED"; } + } + + record RepositoryFailure(String message) implements StorageLocationError { + @Override public String code() { return "REPOSITORY_ERROR"; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationId.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationId.java new file mode 100644 index 0000000..f10aac6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.inventory; + +import java.util.UUID; + +public record StorageLocationId(String value) { + + public StorageLocationId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("StorageLocationId must not be blank"); + } + } + + public static StorageLocationId generate() { + return new StorageLocationId(UUID.randomUUID().toString()); + } + + public static StorageLocationId of(String value) { + return new StorageLocationId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationName.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationName.java new file mode 100644 index 0000000..3d503ac --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationName.java @@ -0,0 +1,16 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Result; + +public record StorageLocationName(String value) { + + public static Result of(String value) { + if (value == null || value.isBlank()) { + return Result.failure(new StorageLocationError.InvalidName("Name must not be blank")); + } + if (value.length() > 100) { + return Result.failure(new StorageLocationError.InvalidName("Name must not exceed 100 characters")); + } + return Result.success(new StorageLocationName(value.trim())); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationRepository.java new file mode 100644 index 0000000..8fb1e5b --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationRepository.java @@ -0,0 +1,22 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; + +import java.util.List; +import java.util.Optional; + +public interface StorageLocationRepository { + + Result> findById(StorageLocationId id); + + Result> findAll(); + + Result> findByStorageType(StorageType storageType); + + Result> findActive(); + + Result existsByName(StorageLocationName name); + + Result save(StorageLocation location); +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationUpdateDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationUpdateDraft.java new file mode 100644 index 0000000..a05dd3a --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageLocationUpdateDraft.java @@ -0,0 +1,16 @@ +package de.effigenix.domain.inventory; + +/** + * Rohe Eingabe für partielle StorageLocation-Updates. + * null-Felder bedeuten "nicht ändern". + * StorageType ist immutable und daher nicht Teil des UpdateDraft. + * + * @param name null = nicht ändern + * @param minTemperature BigDecimal als String, nullable + * @param maxTemperature BigDecimal als String, nullable + */ +public record StorageLocationUpdateDraft( + String name, + String minTemperature, + String maxTemperature +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StorageType.java b/backend/src/main/java/de/effigenix/domain/inventory/StorageType.java new file mode 100644 index 0000000..44ea6cb --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StorageType.java @@ -0,0 +1,9 @@ +package de.effigenix.domain.inventory; + +public enum StorageType { + COLD_ROOM, + FREEZER, + DRY_STORAGE, + DISPLAY_COUNTER, + PRODUCTION_AREA +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/TemperatureRange.java b/backend/src/main/java/de/effigenix/domain/inventory/TemperatureRange.java new file mode 100644 index 0000000..9fdfa82 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/TemperatureRange.java @@ -0,0 +1,37 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; + +public record TemperatureRange(BigDecimal minTemperature, BigDecimal maxTemperature) { + + private static final BigDecimal MIN_ALLOWED = new BigDecimal("-50"); + private static final BigDecimal MAX_ALLOWED = new BigDecimal("80"); + + public static Result of( + BigDecimal minTemperature, BigDecimal maxTemperature) { + if (minTemperature == null || maxTemperature == null) { + return Result.failure(new StorageLocationError.InvalidTemperatureRange( + "Both min and max temperature must be provided")); + } + if (minTemperature.compareTo(maxTemperature) >= 0) { + return Result.failure(new StorageLocationError.InvalidTemperatureRange( + "Min temperature must be less than max temperature")); + } + if (minTemperature.compareTo(MIN_ALLOWED) < 0 || minTemperature.compareTo(MAX_ALLOWED) > 0) { + return Result.failure(new StorageLocationError.InvalidTemperatureRange( + "Min temperature must be between -50°C and +80°C")); + } + if (maxTemperature.compareTo(MIN_ALLOWED) < 0 || maxTemperature.compareTo(MAX_ALLOWED) > 0) { + return Result.failure(new StorageLocationError.InvalidTemperatureRange( + "Max temperature must be between -50°C and +80°C")); + } + return Result.success(new TemperatureRange(minTemperature, maxTemperature)); + } + + public boolean contains(BigDecimal temperature) { + return temperature.compareTo(minTemperature) >= 0 + && temperature.compareTo(maxTemperature) <= 0; + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java new file mode 100644 index 0000000..5deebf5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -0,0 +1,17 @@ +package de.effigenix.infrastructure.config; + +import de.effigenix.application.inventory.CreateStorageLocation; +import de.effigenix.domain.inventory.StorageLocationRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class InventoryUseCaseConfiguration { + + // ==================== StorageLocation Use Cases ==================== + + @Bean + public CreateStorageLocation createStorageLocation(StorageLocationRepository storageLocationRepository) { + return new CreateStorageLocation(storageLocationRepository); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StorageLocationEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StorageLocationEntity.java new file mode 100644 index 0000000..a390b6a --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StorageLocationEntity.java @@ -0,0 +1,51 @@ +package de.effigenix.infrastructure.inventory.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; + +@Entity +@Table(name = "storage_locations") +public class StorageLocationEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "storage_type", nullable = false, length = 30) + private String storageType; + + @Column(name = "min_temperature", precision = 5, scale = 1) + private BigDecimal minTemperature; + + @Column(name = "max_temperature", precision = 5, scale = 1) + private BigDecimal maxTemperature; + + @Column(name = "active", nullable = false) + private boolean active; + + public StorageLocationEntity() {} + + // ==================== Getters & Setters ==================== + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getStorageType() { return storageType; } + public void setStorageType(String storageType) { this.storageType = storageType; } + + public BigDecimal getMinTemperature() { return minTemperature; } + public void setMinTemperature(BigDecimal minTemperature) { this.minTemperature = minTemperature; } + + public BigDecimal getMaxTemperature() { return maxTemperature; } + public void setMaxTemperature(BigDecimal maxTemperature) { this.maxTemperature = maxTemperature; } + + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StorageLocationMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StorageLocationMapper.java new file mode 100644 index 0000000..95ddf5d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StorageLocationMapper.java @@ -0,0 +1,40 @@ +package de.effigenix.infrastructure.inventory.persistence.mapper; + +import de.effigenix.domain.inventory.*; +import de.effigenix.infrastructure.inventory.persistence.entity.StorageLocationEntity; +import org.springframework.stereotype.Component; + +@Component +public class StorageLocationMapper { + + public StorageLocationEntity toEntity(StorageLocation location) { + var entity = new StorageLocationEntity(); + entity.setId(location.id().value()); + entity.setName(location.name().value()); + entity.setStorageType(location.storageType().name()); + entity.setActive(location.active()); + + var range = location.temperatureRange(); + if (range != null) { + entity.setMinTemperature(range.minTemperature()); + entity.setMaxTemperature(range.maxTemperature()); + } + + return entity; + } + + public StorageLocation toDomain(StorageLocationEntity entity) { + TemperatureRange temperatureRange = null; + if (entity.getMinTemperature() != null && entity.getMaxTemperature() != null) { + temperatureRange = new TemperatureRange(entity.getMinTemperature(), entity.getMaxTemperature()); + } + + return StorageLocation.reconstitute( + StorageLocationId.of(entity.getId()), + new StorageLocationName(entity.getName()), + StorageType.valueOf(entity.getStorageType()), + temperatureRange, + entity.isActive() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStorageLocationRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStorageLocationRepository.java new file mode 100644 index 0000000..3a2a4dd --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStorageLocationRepository.java @@ -0,0 +1,104 @@ +package de.effigenix.infrastructure.inventory.persistence.repository; + +import de.effigenix.domain.inventory.*; +import de.effigenix.infrastructure.inventory.persistence.mapper.StorageLocationMapper; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +@Profile("!no-db") +@Transactional(readOnly = true) +public class JpaStorageLocationRepository implements StorageLocationRepository { + + private static final Logger logger = LoggerFactory.getLogger(JpaStorageLocationRepository.class); + + private final StorageLocationJpaRepository jpaRepository; + private final StorageLocationMapper mapper; + + public JpaStorageLocationRepository(StorageLocationJpaRepository jpaRepository, StorageLocationMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Result> findById(StorageLocationId id) { + try { + Optional result = jpaRepository.findById(id.value()) + .map(mapper::toDomain); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findById", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAll() { + try { + List result = jpaRepository.findAll().stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAll", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByStorageType(StorageType storageType) { + try { + List result = jpaRepository.findByStorageType(storageType.name()).stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByStorageType", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findActive() { + try { + List result = jpaRepository.findByActiveTrue().stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findActive", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result existsByName(StorageLocationName name) { + try { + return Result.success(jpaRepository.existsByName(name.value())); + } catch (Exception e) { + logger.trace("Database error in existsByName", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + @Transactional + public Result save(StorageLocation location) { + try { + jpaRepository.save(mapper.toEntity(location)); + return Result.success(null); + } catch (Exception e) { + logger.trace("Database error in save", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StorageLocationJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StorageLocationJpaRepository.java new file mode 100644 index 0000000..b7af3c7 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StorageLocationJpaRepository.java @@ -0,0 +1,15 @@ +package de.effigenix.infrastructure.inventory.persistence.repository; + +import de.effigenix.infrastructure.inventory.persistence.entity.StorageLocationEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface StorageLocationJpaRepository extends JpaRepository { + + List findByStorageType(String storageType); + + List findByActiveTrue(); + + boolean existsByName(String name); +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java new file mode 100644 index 0000000..c439afd --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StorageLocationController.java @@ -0,0 +1,77 @@ +package de.effigenix.infrastructure.inventory.web.controller; + +import de.effigenix.application.inventory.CreateStorageLocation; +import de.effigenix.application.inventory.command.CreateStorageLocationCommand; +import de.effigenix.domain.inventory.StorageLocationError; +import de.effigenix.infrastructure.inventory.web.dto.CreateStorageLocationRequest; +import de.effigenix.infrastructure.inventory.web.dto.StorageLocationResponse; +import de.effigenix.shared.security.ActorId; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/inventory/storage-locations") +@SecurityRequirement(name = "Bearer Authentication") +@Tag(name = "Storage Locations", description = "Storage location management endpoints") +public class StorageLocationController { + + private static final Logger logger = LoggerFactory.getLogger(StorageLocationController.class); + + private final CreateStorageLocation createStorageLocation; + + public StorageLocationController(CreateStorageLocation createStorageLocation) { + this.createStorageLocation = createStorageLocation; + } + + @PostMapping + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity createStorageLocation( + @Valid @RequestBody CreateStorageLocationRequest request, + Authentication authentication + ) { + var actorId = extractActorId(authentication); + logger.info("Creating storage location: {} by actor: {}", request.name(), actorId.value()); + + var cmd = new CreateStorageLocationCommand( + request.name(), request.storageType(), + request.minTemperature(), request.maxTemperature() + ); + var result = createStorageLocation.execute(cmd, actorId); + + if (result.isFailure()) { + throw new StorageLocationDomainErrorException(result.unsafeGetError()); + } + + logger.info("Storage location created: {}", request.name()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(StorageLocationResponse.from(result.unsafeGetValue())); + } + + private ActorId extractActorId(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + throw new IllegalStateException("No authentication found in SecurityContext"); + } + return ActorId.of(authentication.getName()); + } + + public static class StorageLocationDomainErrorException extends RuntimeException { + private final StorageLocationError error; + + public StorageLocationDomainErrorException(StorageLocationError error) { + super(error.message()); + this.error = error; + } + + public StorageLocationError getError() { + return error; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CreateStorageLocationRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CreateStorageLocationRequest.java new file mode 100644 index 0000000..2e9bd48 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CreateStorageLocationRequest.java @@ -0,0 +1,10 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateStorageLocationRequest( + @NotBlank String name, + @NotBlank String storageType, + String minTemperature, + String maxTemperature +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StorageLocationResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StorageLocationResponse.java new file mode 100644 index 0000000..83582a6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StorageLocationResponse.java @@ -0,0 +1,34 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import de.effigenix.domain.inventory.StorageLocation; + +import java.math.BigDecimal; + +public record StorageLocationResponse( + String id, + String name, + String storageType, + TemperatureRangeResponse temperatureRange, + boolean active +) { + + public static StorageLocationResponse from(StorageLocation location) { + TemperatureRangeResponse tempRange = null; + if (location.temperatureRange() != null) { + tempRange = new TemperatureRangeResponse( + location.temperatureRange().minTemperature(), + location.temperatureRange().maxTemperature() + ); + } + + return new StorageLocationResponse( + location.id().value(), + location.name().value(), + location.storageType().name(), + tempRange, + location.active() + ); + } + + public record TemperatureRangeResponse(BigDecimal minTemperature, BigDecimal maxTemperature) {} +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java new file mode 100644 index 0000000..9c8d4c0 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java @@ -0,0 +1,23 @@ +package de.effigenix.infrastructure.inventory.web.exception; + +import de.effigenix.domain.inventory.StorageLocationError; + +public final class InventoryErrorHttpStatusMapper { + + private InventoryErrorHttpStatusMapper() {} + + public static int toHttpStatus(StorageLocationError error) { + return switch (error) { + case StorageLocationError.StorageLocationNotFound e -> 404; + case StorageLocationError.NameAlreadyExists e -> 409; + case StorageLocationError.StockExistsAtLocation e -> 409; + case StorageLocationError.AlreadyActive e -> 409; + case StorageLocationError.AlreadyInactive e -> 409; + case StorageLocationError.InvalidName e -> 400; + case StorageLocationError.InvalidTemperatureRange e -> 400; + case StorageLocationError.InvalidStorageType e -> 400; + case StorageLocationError.Unauthorized e -> 403; + case StorageLocationError.RepositoryFailure e -> 500; + }; + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java index cbf4a55..9e467ed 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java @@ -1,10 +1,13 @@ package de.effigenix.infrastructure.usermanagement.web.exception; +import de.effigenix.domain.inventory.StorageLocationError; import de.effigenix.domain.masterdata.ArticleError; import de.effigenix.domain.masterdata.ProductCategoryError; import de.effigenix.domain.masterdata.CustomerError; import de.effigenix.domain.masterdata.SupplierError; import de.effigenix.domain.usermanagement.UserError; +import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController; +import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper; import de.effigenix.infrastructure.masterdata.web.controller.ArticleController; import de.effigenix.infrastructure.masterdata.web.controller.CustomerController; import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController; @@ -179,6 +182,25 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(errorResponse); } + @ExceptionHandler(StorageLocationController.StorageLocationDomainErrorException.class) + public ResponseEntity handleStorageLocationDomainError( + StorageLocationController.StorageLocationDomainErrorException ex, + HttpServletRequest request + ) { + StorageLocationError error = ex.getError(); + int status = InventoryErrorHttpStatusMapper.toHttpStatus(error); + logDomainError("StorageLocation", error.code(), error.message(), status); + + ErrorResponse errorResponse = ErrorResponse.from( + error.code(), + error.message(), + status, + request.getRequestURI() + ); + + return ResponseEntity.status(status).body(errorResponse); + } + @ExceptionHandler(RoleController.RoleDomainErrorException.class) public ResponseEntity handleRoleDomainError( RoleController.RoleDomainErrorException ex, diff --git a/backend/src/main/resources/db/changelog/changes/009-create-storage-location-schema.xml b/backend/src/main/resources/db/changelog/changes/009-create-storage-location-schema.xml new file mode 100644 index 0000000..e1fa0d0 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/009-create-storage-location-schema.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE storage_locations ADD CONSTRAINT chk_storage_type CHECK (storage_type IN ( + 'COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA' + )); + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index e28af5a..9d19a6a 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -13,5 +13,6 @@ + diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StorageLocationTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StorageLocationTest.java new file mode 100644 index 0000000..ee09ce1 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/StorageLocationTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java new file mode 100644 index 0000000..9ab7fac --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StorageLocationControllerIntegrationTest.java @@ -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(); + } +}