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

refactor(production): UnitOfWork-Pattern + JdbcClient-Migration für Production-BC

Production-BC von JPA auf JdbcClient migriert und UnitOfWork-Port
eingeführt, der Transaktionen explizit steuert und bei Result.failure
zurückrollt — löst das Problem, dass @Transactional bei funktionalem
Error-Handling keinen Rollback auslöst.

- UnitOfWork-Interface (shared) + SpringUnitOfWork-Implementierung
- JdbcProductionOrderRepository, JdbcRecipeRepository, JdbcBatchRepository,
  JdbcBatchNumberGenerator ersetzen JPA-Pendants
- 17 JPA-Dateien entfernt (Entities, Mapper, Spring Data Interfaces)
- Alle Production-Use-Cases nutzen UnitOfWork statt @Transactional
- Liquibase-Changelogs H2-kompatibel gemacht (dbms-Attribute)
- Tests auf Liquibase-Schema umgestellt (ddl-auto: none)
This commit is contained in:
Sebastian Frick 2026-02-25 00:18:44 +01:00
parent bfae3eff73
commit e5bc5690da
64 changed files with 1248 additions and 1585 deletions

View file

@ -7,6 +7,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -19,6 +20,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -30,14 +32,16 @@ class ActivateRecipeTest {
@Mock private RecipeRepository recipeRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private ActivateRecipe activateRecipe;
private ActorId performedBy;
@BeforeEach
void setUp() {
activateRecipe = new ActivateRecipe(recipeRepository, authPort);
activateRecipe = new ActivateRecipe(recipeRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Recipe draftRecipeWithIngredient() {

View file

@ -5,6 +5,7 @@ import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -14,6 +15,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -26,14 +28,16 @@ class AddRecipeIngredientTest {
@Mock private RecipeRepository recipeRepository;
@Mock private AuthorizationPort authPort;
@Mock private RecipeCycleChecker cycleChecker;
@Mock private UnitOfWork unitOfWork;
private AddRecipeIngredient addRecipeIngredient;
private ActorId performedBy;
@BeforeEach
void setUp() {
addRecipeIngredient = new AddRecipeIngredient(recipeRepository, authPort, cycleChecker);
addRecipeIngredient = new AddRecipeIngredient(recipeRepository, authPort, cycleChecker, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Recipe draftRecipe() {

View file

@ -7,6 +7,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -19,6 +20,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -30,14 +32,16 @@ class ArchiveRecipeTest {
@Mock private RecipeRepository recipeRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private ArchiveRecipe archiveRecipe;
private ActorId performedBy;
@BeforeEach
void setUp() {
archiveRecipe = new ArchiveRecipe(recipeRepository, authPort);
archiveRecipe = new ArchiveRecipe(recipeRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Recipe activeRecipe() {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ -31,14 +33,16 @@ class CancelBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private CancelBatch cancelBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
cancelBatch = new CancelBatch(batchRepository, authPort);
cancelBatch = new CancelBatch(batchRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Batch plannedBatch(String id) {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ -31,14 +33,16 @@ class CompleteBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private CompleteBatch completeBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
completeBatch = new CompleteBatch(batchRepository, authPort);
completeBatch = new CompleteBatch(batchRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Batch inProductionBatchWithConsumption(String id) {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -33,6 +35,7 @@ class CreateProductionOrderTest {
@Mock private ProductionOrderRepository productionOrderRepository;
@Mock private RecipeRepository recipeRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private CreateProductionOrder createProductionOrder;
private ActorId performedBy;
@ -41,8 +44,9 @@ class CreateProductionOrderTest {
@BeforeEach
void setUp() {
createProductionOrder = new CreateProductionOrder(productionOrderRepository, recipeRepository, authPort);
createProductionOrder = new CreateProductionOrder(productionOrderRepository, recipeRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private CreateProductionOrderCommand validCommand() {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -34,6 +36,7 @@ class PlanBatchTest {
@Mock private RecipeRepository recipeRepository;
@Mock private BatchNumberGenerator batchNumberGenerator;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private PlanBatch planBatch;
private ActorId performedBy;
@ -44,8 +47,9 @@ class PlanBatchTest {
@BeforeEach
void setUp() {
planBatch = new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authPort);
planBatch = new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private PlanBatchCommand validCommand() {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ -31,14 +33,16 @@ class RecordConsumptionTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private RecordConsumption recordConsumption;
private ActorId performedBy;
@BeforeEach
void setUp() {
recordConsumption = new RecordConsumption(batchRepository, authPort);
recordConsumption = new RecordConsumption(batchRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Batch inProductionBatch(String id) {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -33,6 +35,7 @@ class ReleaseProductionOrderTest {
@Mock private ProductionOrderRepository productionOrderRepository;
@Mock private RecipeRepository recipeRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private ReleaseProductionOrder releaseProductionOrder;
private ActorId performedBy;
@ -41,8 +44,9 @@ class ReleaseProductionOrderTest {
@BeforeEach
void setUp() {
releaseProductionOrder = new ReleaseProductionOrder(productionOrderRepository, recipeRepository, authPort);
releaseProductionOrder = new ReleaseProductionOrder(productionOrderRepository, recipeRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private ReleaseProductionOrderCommand validCommand() {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ -31,14 +33,16 @@ class StartBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private StartBatch startBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
startBatch = new StartBatch(batchRepository, authPort);
startBatch = new StartBatch(batchRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private Batch plannedBatch(String id) {

View file

@ -8,6 +8,7 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,6 +22,7 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -33,6 +35,7 @@ class StartProductionOrderTest {
@Mock private ProductionOrderRepository productionOrderRepository;
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
@Mock private UnitOfWork unitOfWork;
private StartProductionOrder startProductionOrder;
private ActorId performedBy;
@ -41,8 +44,9 @@ class StartProductionOrderTest {
@BeforeEach
void setUp() {
startProductionOrder = new StartProductionOrder(productionOrderRepository, batchRepository, authPort);
startProductionOrder = new StartProductionOrder(productionOrderRepository, batchRepository, authPort, unitOfWork);
performedBy = ActorId.of("admin-user");
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
}
private StartProductionOrderCommand validCommand() {

View file

@ -3,6 +3,10 @@ package de.effigenix.infrastructure;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.domain.usermanagement.UserStatus;
import de.effigenix.infrastructure.masterdata.persistence.entity.ArticleEntity;
import de.effigenix.infrastructure.masterdata.persistence.entity.ProductCategoryEntity;
import de.effigenix.infrastructure.masterdata.persistence.repository.ArticleJpaRepository;
import de.effigenix.infrastructure.masterdata.persistence.repository.ProductCategoryJpaRepository;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository;
@ -45,6 +49,12 @@ public abstract class AbstractIntegrationTest {
@Autowired
protected RoleJpaRepository roleRepository;
@Autowired
protected ArticleJpaRepository articleRepository;
@Autowired
protected ProductCategoryJpaRepository productCategoryRepository;
@Value("${jwt.secret}")
protected String jwtSecret;
@ -94,9 +104,11 @@ public abstract class AbstractIntegrationTest {
}
protected RoleEntity createRole(RoleName roleName, String description) {
RoleEntity role = new RoleEntity(
UUID.randomUUID().toString(), roleName, Set.of(), description);
return roleRepository.save(role);
return roleRepository.findByName(roleName).orElseGet(() -> {
RoleEntity role = new RoleEntity(
UUID.randomUUID().toString(), roleName, Set.of(), description);
return roleRepository.save(role);
});
}
protected UserEntity createUser(String username, String email, Set<RoleEntity> roles, String branchId) {
@ -106,4 +118,14 @@ public abstract class AbstractIntegrationTest {
branchId, UserStatus.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC), null);
return userRepository.save(user);
}
protected String createArticleId() {
String categoryId = UUID.randomUUID().toString();
productCategoryRepository.save(new ProductCategoryEntity(categoryId, "TestCat-" + categoryId.substring(0, 8), null));
var now = OffsetDateTime.now(ZoneOffset.UTC);
var article = new ArticleEntity(
UUID.randomUUID().toString(), "TestArticle-" + UUID.randomUUID().toString().substring(0, 8),
"ART-" + UUID.randomUUID().toString().substring(0, 8), categoryId, "ACTIVE", now, now);
return articleRepository.save(article).getId();
}
}

View file

@ -52,13 +52,17 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
storageLocationId = createStorageLocation();
}
private String newArticleId() {
return createArticleId();
}
// ==================== Bestandsposition anlegen Pflichtfelder ====================
@Test
@DisplayName("Bestandsposition mit Pflichtfeldern erstellen → 201")
void createStock_withRequiredFields_returns201() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, null);
newArticleId(), storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
@ -78,7 +82,7 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
@DisplayName("Bestandsposition mit allen Feldern erstellen → 201")
void createStock_withAllFields_returns201() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, "10.5", "KILOGRAM", 30);
newArticleId(), storageLocationId, "10.5", "KILOGRAM", 30);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
@ -97,7 +101,7 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
@Test
@DisplayName("Bestandsposition Duplikat (gleiche articleId+storageLocationId) → 409")
void createStock_duplicate_returns409() throws Exception {
String articleId = UUID.randomUUID().toString();
String articleId = newArticleId();
var request = new CreateStockRequest(articleId, storageLocationId, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
@ -942,7 +946,7 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
@Test
@DisplayName("Bestandspositionen nach articleId filtern → 200")
void listStocks_filterByArticleId() throws Exception {
String articleId = UUID.randomUUID().toString();
String articleId = newArticleId();
createStockForArticle(articleId);
mockMvc.perform(get("/api/inventory/stocks")
@ -1512,7 +1516,7 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
private String createStock() throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, null, null, null);
newArticleId(), storageLocationId, null, null, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
@ -1526,7 +1530,7 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
private String createStockForLocation(String locationId) throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), locationId, null, null, null);
newArticleId(), locationId, null, null, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
@ -1540,7 +1544,7 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
private String createStockWithMinimumLevel(String minimumAmount, String unit) throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, minimumAmount, unit, null);
newArticleId(), storageLocationId, minimumAmount, unit, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)

View file

@ -408,7 +408,7 @@ class StorageLocationControllerIntegrationTest extends AbstractIntegrationTest {
// Stock an diesem Lagerort anlegen
var stockRequest = new CreateStockRequest(
UUID.randomUUID().toString(), id, null, null, null);
createArticleId(), id, null, null, null);
mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)

View file

@ -2,7 +2,6 @@ package de.effigenix.infrastructure.production.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;