1
0
Fork 0
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:
Sebastian Frick 2026-02-19 09:51:48 +01:00
parent 554185a012
commit c474388f32
25 changed files with 1527 additions and 0 deletions

View file

@ -0,0 +1,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);
}
}

View file

@ -0,0 +1,8 @@
package de.effigenix.application.inventory.command;
public record CreateStorageLocationCommand(
String name,
String storageType,
String minTemperature,
String maxTemperature
) {}

View file

@ -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 + "}";
}
}

View file

@ -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
) {}

View file

@ -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"; }
}
}

View file

@ -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);
}
}

View file

@ -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()));
}
}

View file

@ -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);
}

View file

@ -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
) {}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
public enum StorageType {
COLD_ROOM,
FREEZER,
DRY_STORAGE,
DISPLAY_COUNTER,
PRODUCTION_AREA
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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; }
}

View file

@ -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()
);
}
}

View file

@ -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()));
}
}
}

View file

@ -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);
}

View file

@ -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;
}
}
}

View file

@ -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
) {}

View file

@ -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) {}
}

View file

@ -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;
};
}
}

View file

@ -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<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)
public ResponseEntity<ErrorResponse> handleRoleDomainError(
RoleController.RoleDomainErrorException ex,

View file

@ -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>

View file

@ -13,5 +13,6 @@
<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/008-add-masterdata-permissions.xml"/>
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
</databaseChangeLog>

View file

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

View file

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