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

feat(inventory): StorageLocation bearbeiten und (de-)aktivieren (#2)

Use Cases: UpdateStorageLocation, DeactivateStorageLocation, ActivateStorageLocation
Endpoints: PUT /{id}, PATCH /{id}/deactivate, PATCH /{id}/activate
Repository: existsByNameAndIdNot für Uniqueness-Check bei Update
Tests: 14 neue Integrationstests (25 gesamt)
This commit is contained in:
Sebastian Frick 2026-02-19 10:10:57 +01:00
parent 05878b1ce9
commit 24a6869faf
11 changed files with 493 additions and 2 deletions

View file

@ -0,0 +1,47 @@
package de.effigenix.application.inventory;
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 ActivateStorageLocation {
private final StorageLocationRepository storageLocationRepository;
public ActivateStorageLocation(StorageLocationRepository storageLocationRepository) {
this.storageLocationRepository = storageLocationRepository;
}
public Result<StorageLocationError, StorageLocation> execute(String storageLocationId, ActorId performedBy) {
// 1. Laden
var locationId = StorageLocationId.of(storageLocationId);
StorageLocation location;
switch (storageLocationRepository.findById(locationId)) {
case Result.Failure(var err) ->
{ return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new StorageLocationError.StorageLocationNotFound(storageLocationId));
}
location = opt.get();
}
}
// 2. Aktivieren
switch (location.activate()) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
// 3. 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,50 @@
package de.effigenix.application.inventory;
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 DeactivateStorageLocation {
private final StorageLocationRepository storageLocationRepository;
public DeactivateStorageLocation(StorageLocationRepository storageLocationRepository) {
this.storageLocationRepository = storageLocationRepository;
}
public Result<StorageLocationError, StorageLocation> execute(String storageLocationId, ActorId performedBy) {
// 1. Laden
var locationId = StorageLocationId.of(storageLocationId);
StorageLocation location;
switch (storageLocationRepository.findById(locationId)) {
case Result.Failure(var err) ->
{ return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new StorageLocationError.StorageLocationNotFound(storageLocationId));
}
location = opt.get();
}
}
// TODO: Stock-Existenz prüfen, wenn Stock BC implementiert ist
// Akzeptanzkriterium: Deaktivierung schlägt fehl, wenn Stock am Lagerort existiert
// 2. Deaktivieren
switch (location.deactivate()) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
// 3. 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,62 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.UpdateStorageLocationCommand;
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 UpdateStorageLocation {
private final StorageLocationRepository storageLocationRepository;
public UpdateStorageLocation(StorageLocationRepository storageLocationRepository) {
this.storageLocationRepository = storageLocationRepository;
}
public Result<StorageLocationError, StorageLocation> execute(UpdateStorageLocationCommand cmd, ActorId performedBy) {
// 1. Laden
var locationId = StorageLocationId.of(cmd.storageLocationId());
StorageLocation location;
switch (storageLocationRepository.findById(locationId)) {
case Result.Failure(var err) ->
{ return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new StorageLocationError.StorageLocationNotFound(cmd.storageLocationId()));
}
location = opt.get();
}
}
// 2. Draft bauen + Aggregate validieren lassen
var draft = new StorageLocationUpdateDraft(cmd.name(), cmd.minTemperature(), cmd.maxTemperature());
switch (location.update(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
// 3. Uniqueness-Check (nur wenn Name geändert wurde)
if (cmd.name() != null) {
switch (storageLocationRepository.existsByNameAndIdNot(location.name(), locationId)) {
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 UpdateStorageLocationCommand(
String storageLocationId,
String name,
String minTemperature,
String maxTemperature
) {}

View file

@ -18,5 +18,7 @@ public interface StorageLocationRepository {
Result<RepositoryError, Boolean> existsByName(StorageLocationName name);
Result<RepositoryError, Boolean> existsByNameAndIdNot(StorageLocationName name, StorageLocationId id);
Result<RepositoryError, Void> save(StorageLocation location);
}

View file

@ -1,6 +1,9 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.application.inventory.DeactivateStorageLocation;
import de.effigenix.application.inventory.UpdateStorageLocation;
import de.effigenix.domain.inventory.StorageLocationRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -14,4 +17,19 @@ public class InventoryUseCaseConfiguration {
public CreateStorageLocation createStorageLocation(StorageLocationRepository storageLocationRepository) {
return new CreateStorageLocation(storageLocationRepository);
}
@Bean
public UpdateStorageLocation updateStorageLocation(StorageLocationRepository storageLocationRepository) {
return new UpdateStorageLocation(storageLocationRepository);
}
@Bean
public DeactivateStorageLocation deactivateStorageLocation(StorageLocationRepository storageLocationRepository) {
return new DeactivateStorageLocation(storageLocationRepository);
}
@Bean
public ActivateStorageLocation activateStorageLocation(StorageLocationRepository storageLocationRepository) {
return new ActivateStorageLocation(storageLocationRepository);
}
}

View file

@ -90,6 +90,16 @@ public class JpaStorageLocationRepository implements StorageLocationRepository {
}
}
@Override
public Result<RepositoryError, Boolean> existsByNameAndIdNot(StorageLocationName name, StorageLocationId id) {
try {
return Result.success(jpaRepository.existsByNameAndIdNot(name.value(), id.value()));
} catch (Exception e) {
logger.trace("Database error in existsByNameAndIdNot", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(StorageLocation location) {

View file

@ -12,4 +12,6 @@ public interface StorageLocationJpaRepository extends JpaRepository<StorageLocat
List<StorageLocationEntity> findByActiveTrue();
boolean existsByName(String name);
boolean existsByNameAndIdNot(String name, String id);
}

View file

@ -1,10 +1,15 @@
package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.application.inventory.DeactivateStorageLocation;
import de.effigenix.application.inventory.UpdateStorageLocation;
import de.effigenix.application.inventory.command.CreateStorageLocationCommand;
import de.effigenix.application.inventory.command.UpdateStorageLocationCommand;
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.infrastructure.inventory.web.dto.UpdateStorageLocationRequest;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -26,9 +31,20 @@ public class StorageLocationController {
private static final Logger logger = LoggerFactory.getLogger(StorageLocationController.class);
private final CreateStorageLocation createStorageLocation;
private final UpdateStorageLocation updateStorageLocation;
private final DeactivateStorageLocation deactivateStorageLocation;
private final ActivateStorageLocation activateStorageLocation;
public StorageLocationController(CreateStorageLocation createStorageLocation) {
public StorageLocationController(
CreateStorageLocation createStorageLocation,
UpdateStorageLocation updateStorageLocation,
DeactivateStorageLocation deactivateStorageLocation,
ActivateStorageLocation activateStorageLocation
) {
this.createStorageLocation = createStorageLocation;
this.updateStorageLocation = updateStorageLocation;
this.deactivateStorageLocation = deactivateStorageLocation;
this.activateStorageLocation = activateStorageLocation;
}
@PostMapping
@ -55,6 +71,68 @@ public class StorageLocationController {
.body(StorageLocationResponse.from(result.unsafeGetValue()));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StorageLocationResponse> updateStorageLocation(
@PathVariable("id") String storageLocationId,
@RequestBody UpdateStorageLocationRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Updating storage location: {} by actor: {}", storageLocationId, actorId.value());
var cmd = new UpdateStorageLocationCommand(
storageLocationId, request.name(),
request.minTemperature(), request.maxTemperature()
);
var result = updateStorageLocation.execute(cmd, actorId);
if (result.isFailure()) {
throw new StorageLocationDomainErrorException(result.unsafeGetError());
}
logger.info("Storage location updated: {}", storageLocationId);
return ResponseEntity.ok(StorageLocationResponse.from(result.unsafeGetValue()));
}
@PatchMapping("/{id}/deactivate")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StorageLocationResponse> deactivateStorageLocation(
@PathVariable("id") String storageLocationId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Deactivating storage location: {} by actor: {}", storageLocationId, actorId.value());
var result = deactivateStorageLocation.execute(storageLocationId, actorId);
if (result.isFailure()) {
throw new StorageLocationDomainErrorException(result.unsafeGetError());
}
logger.info("Storage location deactivated: {}", storageLocationId);
return ResponseEntity.ok(StorageLocationResponse.from(result.unsafeGetValue()));
}
@PatchMapping("/{id}/activate")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StorageLocationResponse> activateStorageLocation(
@PathVariable("id") String storageLocationId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Activating storage location: {} by actor: {}", storageLocationId, actorId.value());
var result = activateStorageLocation.execute(storageLocationId, actorId);
if (result.isFailure()) {
throw new StorageLocationDomainErrorException(result.unsafeGetError());
}
logger.info("Storage location activated: {}", storageLocationId);
return ResponseEntity.ok(StorageLocationResponse.from(result.unsafeGetValue()));
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");

View file

@ -0,0 +1,7 @@
package de.effigenix.infrastructure.inventory.web.dto;
public record UpdateStorageLocationRequest(
String name,
String minTemperature,
String maxTemperature
) {}

View file

@ -4,6 +4,7 @@ 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.inventory.web.dto.UpdateStorageLocationRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository;
@ -35,7 +36,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* Integrationstests für StorageLocationController.
*
* Abgedeckte Testfälle: Story 1.1 Lagerort anlegen
* Abgedeckte Testfälle:
* - Story 1.1 Lagerort anlegen
* - Story 1.2 Lagerort bearbeiten und (de-)aktivieren
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ -273,8 +276,212 @@ class StorageLocationControllerIntegrationTest {
}
}
// ==================== Lagerort bearbeiten (Story 1.2) ====================
@Test
@DisplayName("Lagerort Name ändern → 200")
void updateStorageLocation_changeName_returns200() throws Exception {
String id = createAndReturnId("Update Test", "DRY_STORAGE", null, null);
var request = new UpdateStorageLocationRequest("Neuer Name", null, null);
mockMvc.perform(put("/api/inventory/storage-locations/" + id)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.name").value("Neuer Name"))
.andExpect(jsonPath("$.storageType").value("DRY_STORAGE"));
}
@Test
@DisplayName("Lagerort Temperaturbereich ändern → 200")
void updateStorageLocation_changeTemperatureRange_returns200() throws Exception {
String id = createAndReturnId("Temp Update", "COLD_ROOM", "-2", "8");
var request = new UpdateStorageLocationRequest(null, "-5", "12");
mockMvc.perform(put("/api/inventory/storage-locations/" + id)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Temp Update"))
.andExpect(jsonPath("$.temperatureRange.minTemperature").value(-5))
.andExpect(jsonPath("$.temperatureRange.maxTemperature").value(12));
}
@Test
@DisplayName("Lagerort mit ungültigem Namen aktualisieren → 400")
void updateStorageLocation_withBlankName_returns400() throws Exception {
String id = createAndReturnId("Blank Update", "DRY_STORAGE", null, null);
var request = new UpdateStorageLocationRequest("", null, null);
mockMvc.perform(put("/api/inventory/storage-locations/" + id)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_STORAGE_LOCATION_NAME"));
}
@Test
@DisplayName("Lagerort mit doppeltem Namen aktualisieren → 409")
void updateStorageLocation_withDuplicateName_returns409() throws Exception {
createAndReturnId("Existing Name", "DRY_STORAGE", null, null);
String id = createAndReturnId("To Rename", "COLD_ROOM", null, null);
var request = new UpdateStorageLocationRequest("Existing Name", null, null);
mockMvc.perform(put("/api/inventory/storage-locations/" + id)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NAME_EXISTS"));
}
@Test
@DisplayName("Lagerort mit eigenem Namen aktualisieren → 200 (kein Duplikat)")
void updateStorageLocation_withOwnName_returns200() throws Exception {
String id = createAndReturnId("Same Name", "DRY_STORAGE", null, null);
var request = new UpdateStorageLocationRequest("Same Name", null, null);
mockMvc.perform(put("/api/inventory/storage-locations/" + id)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Same Name"));
}
@Test
@DisplayName("Nicht existierenden Lagerort aktualisieren → 404")
void updateStorageLocation_notFound_returns404() throws Exception {
var request = new UpdateStorageLocationRequest("New Name", null, null);
mockMvc.perform(put("/api/inventory/storage-locations/" + UUID.randomUUID())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NOT_FOUND"));
}
@Test
@DisplayName("Lagerort aktualisieren ohne STOCK_WRITE → 403")
void updateStorageLocation_withViewerToken_returns403() throws Exception {
var request = new UpdateStorageLocationRequest("Name", null, null);
mockMvc.perform(put("/api/inventory/storage-locations/" + UUID.randomUUID())
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
// ==================== Lagerort deaktivieren (Story 1.2) ====================
@Test
@DisplayName("Aktiven Lagerort deaktivieren → 200")
void deactivateStorageLocation_active_returns200() throws Exception {
String id = createAndReturnId("Deactivate Test", "DRY_STORAGE", null, null);
mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/deactivate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.active").value(false));
}
@Test
@DisplayName("Bereits inaktiven Lagerort deaktivieren → 409")
void deactivateStorageLocation_alreadyInactive_returns409() throws Exception {
String id = createAndReturnId("Double Deactivate", "DRY_STORAGE", null, null);
mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/deactivate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/deactivate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ALREADY_INACTIVE"));
}
@Test
@DisplayName("Nicht existierenden Lagerort deaktivieren → 404")
void deactivateStorageLocation_notFound_returns404() throws Exception {
mockMvc.perform(patch("/api/inventory/storage-locations/" + UUID.randomUUID() + "/deactivate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NOT_FOUND"));
}
@Test
@DisplayName("Lagerort deaktivieren ohne STOCK_WRITE → 403")
void deactivateStorageLocation_withViewerToken_returns403() throws Exception {
mockMvc.perform(patch("/api/inventory/storage-locations/" + UUID.randomUUID() + "/deactivate")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
// ==================== Lagerort aktivieren (Story 1.2) ====================
@Test
@DisplayName("Inaktiven Lagerort aktivieren → 200")
void activateStorageLocation_inactive_returns200() throws Exception {
String id = createAndReturnId("Activate Test", "DRY_STORAGE", null, null);
mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/deactivate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/activate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.active").value(true));
}
@Test
@DisplayName("Bereits aktiven Lagerort aktivieren → 409")
void activateStorageLocation_alreadyActive_returns409() throws Exception {
String id = createAndReturnId("Double Activate", "DRY_STORAGE", null, null);
mockMvc.perform(patch("/api/inventory/storage-locations/" + id + "/activate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ALREADY_ACTIVE"));
}
@Test
@DisplayName("Nicht existierenden Lagerort aktivieren → 404")
void activateStorageLocation_notFound_returns404() throws Exception {
mockMvc.perform(patch("/api/inventory/storage-locations/" + UUID.randomUUID() + "/activate")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STORAGE_LOCATION_NOT_FOUND"));
}
// ==================== Hilfsmethoden ====================
private String createAndReturnId(String name, String storageType, String minTemp, String maxTemp) throws Exception {
var request = new CreateStorageLocationRequest(name, storageType, minTemp, maxTemp);
var result = mockMvc.perform(post("/api/inventory/storage-locations")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String generateToken(String userId, String username, String permissions) {
long now = System.currentTimeMillis();
javax.crypto.SecretKey key = io.jsonwebtoken.security.Keys.hmacShaKeyFor(