mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +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:
parent
554185a012
commit
c474388f32
25 changed files with 1527 additions and 0 deletions
|
|
@ -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<StorageLocationError, StorageLocation> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package de.effigenix.application.inventory.command;
|
||||||
|
|
||||||
|
public record CreateStorageLocationCommand(
|
||||||
|
String name,
|
||||||
|
String storageType,
|
||||||
|
String minTemperature,
|
||||||
|
String maxTemperature
|
||||||
|
) {}
|
||||||
|
|
@ -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<StorageLocationError, StorageLocation> 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<StorageLocationError, Void> 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<StorageLocationError, Void> deactivate() {
|
||||||
|
if (!this.active) {
|
||||||
|
return Result.failure(new StorageLocationError.AlreadyInactive());
|
||||||
|
}
|
||||||
|
this.active = false;
|
||||||
|
return Result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<StorageLocationError, Void> 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 + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
|
public record StorageLocationName(String value) {
|
||||||
|
|
||||||
|
public static Result<StorageLocationError, StorageLocationName> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RepositoryError, Optional<StorageLocation>> findById(StorageLocationId id);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StorageLocation>> findAll();
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StorageLocation>> findByStorageType(StorageType storageType);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StorageLocation>> findActive();
|
||||||
|
|
||||||
|
Result<RepositoryError, Boolean> existsByName(StorageLocationName name);
|
||||||
|
|
||||||
|
Result<RepositoryError, Void> save(StorageLocation location);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
public enum StorageType {
|
||||||
|
COLD_ROOM,
|
||||||
|
FREEZER,
|
||||||
|
DRY_STORAGE,
|
||||||
|
DISPLAY_COUNTER,
|
||||||
|
PRODUCTION_AREA
|
||||||
|
}
|
||||||
|
|
@ -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<StorageLocationError, TemperatureRange> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RepositoryError, Optional<StorageLocation>> findById(StorageLocationId id) {
|
||||||
|
try {
|
||||||
|
Optional<StorageLocation> 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<RepositoryError, List<StorageLocation>> findAll() {
|
||||||
|
try {
|
||||||
|
List<StorageLocation> 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<RepositoryError, List<StorageLocation>> findByStorageType(StorageType storageType) {
|
||||||
|
try {
|
||||||
|
List<StorageLocation> 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<RepositoryError, List<StorageLocation>> findActive() {
|
||||||
|
try {
|
||||||
|
List<StorageLocation> 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<RepositoryError, Boolean> 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<RepositoryError, Void> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<StorageLocationEntity, String> {
|
||||||
|
|
||||||
|
List<StorageLocationEntity> findByStorageType(String storageType);
|
||||||
|
|
||||||
|
List<StorageLocationEntity> findByActiveTrue();
|
||||||
|
|
||||||
|
boolean existsByName(String name);
|
||||||
|
}
|
||||||
|
|
@ -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<StorageLocationResponse> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package de.effigenix.infrastructure.usermanagement.web.exception;
|
package de.effigenix.infrastructure.usermanagement.web.exception;
|
||||||
|
|
||||||
|
import de.effigenix.domain.inventory.StorageLocationError;
|
||||||
import de.effigenix.domain.masterdata.ArticleError;
|
import de.effigenix.domain.masterdata.ArticleError;
|
||||||
import de.effigenix.domain.masterdata.ProductCategoryError;
|
import de.effigenix.domain.masterdata.ProductCategoryError;
|
||||||
import de.effigenix.domain.masterdata.CustomerError;
|
import de.effigenix.domain.masterdata.CustomerError;
|
||||||
import de.effigenix.domain.masterdata.SupplierError;
|
import de.effigenix.domain.masterdata.SupplierError;
|
||||||
import de.effigenix.domain.usermanagement.UserError;
|
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.ArticleController;
|
||||||
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
|
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
|
||||||
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
|
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
|
||||||
|
|
@ -179,6 +182,25 @@ public class GlobalExceptionHandler {
|
||||||
return ResponseEntity.status(status).body(errorResponse);
|
return ResponseEntity.status(status).body(errorResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(StorageLocationController.StorageLocationDomainErrorException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> 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)
|
@ExceptionHandler(RoleController.RoleDomainErrorException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleRoleDomainError(
|
public ResponseEntity<ErrorResponse> handleRoleDomainError(
|
||||||
RoleController.RoleDomainErrorException ex,
|
RoleController.RoleDomainErrorException ex,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="009-create-storage-locations-table" author="effigenix">
|
||||||
|
<createTable tableName="storage_locations">
|
||||||
|
<column name="id" type="VARCHAR(36)">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" type="VARCHAR(100)">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="storage_type" type="VARCHAR(30)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="min_temperature" type="DECIMAL(5,1)"/>
|
||||||
|
<column name="max_temperature" type="DECIMAL(5,1)"/>
|
||||||
|
<column name="active" type="BOOLEAN" defaultValueBoolean="true">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE storage_locations ADD CONSTRAINT chk_storage_type CHECK (storage_type IN (
|
||||||
|
'COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'
|
||||||
|
));
|
||||||
|
</sql>
|
||||||
|
<createIndex tableName="storage_locations" indexName="idx_storage_locations_name">
|
||||||
|
<column name="name"/>
|
||||||
|
</createIndex>
|
||||||
|
<createIndex tableName="storage_locations" indexName="idx_storage_locations_type">
|
||||||
|
<column name="storage_type"/>
|
||||||
|
</createIndex>
|
||||||
|
<createIndex tableName="storage_locations" indexName="idx_storage_locations_active">
|
||||||
|
<column name="active"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
|
|
@ -13,5 +13,6 @@
|
||||||
<include file="db/changelog/changes/006-create-supplier-schema.xml"/>
|
<include file="db/changelog/changes/006-create-supplier-schema.xml"/>
|
||||||
<include file="db/changelog/changes/007-create-customer-schema.xml"/>
|
<include file="db/changelog/changes/007-create-customer-schema.xml"/>
|
||||||
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/>
|
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/>
|
||||||
|
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue