From 11fb62383b1b99fe5bc14c68e2a61db7f48a44d8 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Tue, 24 Feb 2026 21:44:16 +0100 Subject: [PATCH] =?UTF-8?q?feat(loadtest):=20Gatling-Lasttests=20mit=20~25?= =?UTF-8?q?00=20Requests=20f=C3=BCr=20komprimiertes=20Jahres-Volumen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Szenarien: Stammdaten-CRUD, Produktions-Workflow, Lagerverwaltung, Read-Only-Zugriffe. Batch-Repository auf Summary-Projektion umgestellt, Permissions-Changeset Merge-Konflikt aufgelöst, Unit-Enum im JsonBodyBuilder korrigiert (KILOGRAM → KG). --- backend/pom.xml | 1 + .../application/production/ListBatches.java | 8 +- .../domain/production/BatchRepository.java | 8 + .../FrameContractJpaRepository.java | 3 + .../repository/JpaCustomerRepository.java | 38 +- .../persistence/mapper/BatchMapper.java | 32 + .../repository/JpaBatchRepository.java | 53 ++ .../025-seed-production-order-permissions.xml | 11 +- .../production/ListBatchesTest.java | 24 +- loadtest/README.md | 87 +++ loadtest/pom.xml | 95 +++ .../infrastructure/LoadTestDataSeeder.java | 602 ++++++++++++++++++ .../LoadTestInfrastructure.java | 148 +++++ .../scenario/AuthenticationScenario.java | 64 ++ .../loadtest/scenario/InventoryScenario.java | 88 +++ .../loadtest/scenario/MasterDataScenario.java | 204 ++++++ .../loadtest/scenario/ProductionScenario.java | 165 +++++ .../simulation/FullWorkloadSimulation.java | 118 ++++ .../loadtest/util/JsonBodyBuilder.java | 97 +++ loadtest/src/test/resources/gatling.conf | 19 + loadtest/src/test/resources/logback-test.xml | 29 + 21 files changed, 1856 insertions(+), 38 deletions(-) create mode 100644 loadtest/README.md create mode 100644 loadtest/pom.xml create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java create mode 100644 loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java create mode 100644 loadtest/src/test/resources/gatling.conf create mode 100644 loadtest/src/test/resources/logback-test.xml diff --git a/backend/pom.xml b/backend/pom.xml index 1449c3a..b5ed8df 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -128,6 +128,7 @@ org.springframework.boot spring-boot-maven-plugin + exec org.projectlombok diff --git a/backend/src/main/java/de/effigenix/application/production/ListBatches.java b/backend/src/main/java/de/effigenix/application/production/ListBatches.java index d27e55d..27799b0 100644 --- a/backend/src/main/java/de/effigenix/application/production/ListBatches.java +++ b/backend/src/main/java/de/effigenix/application/production/ListBatches.java @@ -28,7 +28,7 @@ public class ListBatches { return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); } - switch (batchRepository.findAll()) { + switch (batchRepository.findAllSummary()) { case Result.Failure(var err) -> { return Result.failure(new BatchError.RepositoryFailure(err.message())); } case Result.Success(var batches) -> @@ -41,7 +41,7 @@ public class ListBatches { return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); } - switch (batchRepository.findByStatus(status)) { + switch (batchRepository.findByStatusSummary(status)) { case Result.Failure(var err) -> { return Result.failure(new BatchError.RepositoryFailure(err.message())); } case Result.Success(var batches) -> @@ -54,7 +54,7 @@ public class ListBatches { return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); } - switch (batchRepository.findByProductionDate(date)) { + switch (batchRepository.findByProductionDateSummary(date)) { case Result.Failure(var err) -> { return Result.failure(new BatchError.RepositoryFailure(err.message())); } case Result.Success(var batches) -> @@ -75,7 +75,7 @@ public class ListBatches { return Result.success(List.of()); } List recipeIds = recipes.stream().map(Recipe::id).toList(); - switch (batchRepository.findByRecipeIds(recipeIds)) { + switch (batchRepository.findByRecipeIdsSummary(recipeIds)) { case Result.Failure(var batchErr) -> { return Result.failure(new BatchError.RepositoryFailure(batchErr.message())); } case Result.Success(var batches) -> diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java index 65a769a..084d772 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java @@ -21,5 +21,13 @@ public interface BatchRepository { Result> findByRecipeIds(List recipeIds); + Result> findAllSummary(); + + Result> findByStatusSummary(BatchStatus status); + + Result> findByProductionDateSummary(LocalDate date); + + Result> findByRecipeIdsSummary(List recipeIds); + Result save(Batch batch); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java index 16d906a..4f93e19 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java @@ -3,11 +3,14 @@ package de.effigenix.infrastructure.masterdata.persistence.repository; import de.effigenix.infrastructure.masterdata.persistence.entity.FrameContractEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface FrameContractJpaRepository extends JpaRepository { Optional findByCustomerId(String customerId); + List findByCustomerIdIn(List customerIds); + void deleteByCustomerId(String customerId); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java index e64a1fe..701dbe3 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java @@ -1,6 +1,7 @@ package de.effigenix.infrastructure.masterdata.persistence.repository; import de.effigenix.domain.masterdata.*; +import de.effigenix.infrastructure.masterdata.persistence.entity.CustomerEntity; import de.effigenix.infrastructure.masterdata.persistence.entity.FrameContractEntity; import de.effigenix.infrastructure.masterdata.persistence.mapper.CustomerMapper; import de.effigenix.shared.common.RepositoryError; @@ -12,7 +13,9 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Collectors; @Repository @@ -52,13 +55,7 @@ public class JpaCustomerRepository implements CustomerRepository { @Override public Result> findAll() { try { - List result = jpaRepository.findAll().stream() - .map(entity -> { - var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null); - return mapper.toDomain(entity, fc); - }) - .collect(Collectors.toList()); - return Result.success(result); + return Result.success(mapWithFrameContracts(jpaRepository.findAll())); } catch (Exception e) { logger.trace("Database error in findAll", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); @@ -68,13 +65,7 @@ public class JpaCustomerRepository implements CustomerRepository { @Override public Result> findByType(CustomerType type) { try { - List result = jpaRepository.findByType(type.name()).stream() - .map(entity -> { - var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null); - return mapper.toDomain(entity, fc); - }) - .collect(Collectors.toList()); - return Result.success(result); + return Result.success(mapWithFrameContracts(jpaRepository.findByType(type.name()))); } catch (Exception e) { logger.trace("Database error in findByType", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); @@ -84,19 +75,24 @@ public class JpaCustomerRepository implements CustomerRepository { @Override public Result> findByStatus(CustomerStatus status) { try { - List result = jpaRepository.findByStatus(status.name()).stream() - .map(entity -> { - var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null); - return mapper.toDomain(entity, fc); - }) - .collect(Collectors.toList()); - return Result.success(result); + return Result.success(mapWithFrameContracts(jpaRepository.findByStatus(status.name()))); } catch (Exception e) { logger.trace("Database error in findByStatus", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); } } + private List mapWithFrameContracts(List entities) { + List customerIds = entities.stream() + .map(CustomerEntity::getId) + .toList(); + Map fcMap = frameContractJpaRepository.findByCustomerIdIn(customerIds).stream() + .collect(Collectors.toMap(FrameContractEntity::getCustomerId, Function.identity())); + return entities.stream() + .map(entity -> mapper.toDomain(entity, fcMap.get(entity.getId()))) + .collect(Collectors.toList()); + } + @Override @Transactional public Result save(Customer customer) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java index ec161a6..27e9824 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java @@ -128,6 +128,38 @@ public class BatchMapper { ); } + public Batch toDomainSummary(BatchEntity entity) { + Quantity actualQuantity = entity.getActualQuantityAmount() != null + ? Quantity.reconstitute(entity.getActualQuantityAmount(), UnitOfMeasure.valueOf(entity.getActualQuantityUnit())) + : null; + Quantity waste = entity.getWasteAmount() != null + ? Quantity.reconstitute(entity.getWasteAmount(), UnitOfMeasure.valueOf(entity.getWasteUnit())) + : null; + + return Batch.reconstitute( + BatchId.of(entity.getId()), + new BatchNumber(entity.getBatchNumber()), + RecipeId.of(entity.getRecipeId()), + BatchStatus.valueOf(entity.getStatus()), + Quantity.reconstitute( + entity.getPlannedQuantityAmount(), + UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()) + ), + actualQuantity, + waste, + entity.getRemarks(), + entity.getProductionDate(), + entity.getBestBeforeDate(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getCompletedAt(), + entity.getCancellationReason(), + entity.getCancelledAt(), + entity.getVersion(), + List.of() + ); + } + private ConsumptionEntity toConsumptionEntity(Consumption c, BatchEntity parent) { return new ConsumptionEntity( c.id().value(), diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java index 357d30f..82271de 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java @@ -108,6 +108,59 @@ public class JpaBatchRepository implements BatchRepository { } } + @Override + public Result> findAllSummary() { + try { + List result = jpaRepository.findAll().stream() + .map(mapper::toDomainSummary) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAllSummary", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByStatusSummary(BatchStatus status) { + try { + List result = jpaRepository.findByStatus(status.name()).stream() + .map(mapper::toDomainSummary) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByStatusSummary", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByProductionDateSummary(LocalDate date) { + try { + List result = jpaRepository.findByProductionDate(date).stream() + .map(mapper::toDomainSummary) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByProductionDateSummary", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByRecipeIdsSummary(List recipeIds) { + try { + List ids = recipeIds.stream().map(RecipeId::value).toList(); + List result = jpaRepository.findByRecipeIdIn(ids).stream() + .map(mapper::toDomainSummary) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByRecipeIdsSummary", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override @Transactional public Result save(Batch batch) { diff --git a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml index f926a71..08c0aa1 100644 --- a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml +++ b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml @@ -6,7 +6,16 @@ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (idempotent) + + + + SELECT COUNT(*) FROM role_permissions + WHERE role_id = 'c0a80121-0000-0000-0000-000000000001' + AND permission = 'PRODUCTION_ORDER_READ' + + + + Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (skipped if already present from 002) INSERT INTO role_permissions (role_id, permission) VALUES diff --git a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java index 4de2203..d47b50a 100644 --- a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java @@ -81,7 +81,7 @@ class ListBatchesTest { void should_ReturnAllBatches() { var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED), sampleBatch("b2", BatchStatus.PLANNED)); when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findAll()).thenReturn(Result.success(batches)); + when(batchRepository.findAllSummary()).thenReturn(Result.success(batches)); var result = listBatches.execute(performedBy); @@ -93,7 +93,7 @@ class ListBatchesTest { @DisplayName("should return empty list when no batches exist") void should_ReturnEmptyList_When_NoBatchesExist() { when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findAll()).thenReturn(Result.success(List.of())); + when(batchRepository.findAllSummary()).thenReturn(Result.success(List.of())); var result = listBatches.execute(performedBy); @@ -105,7 +105,7 @@ class ListBatchesTest { @DisplayName("should fail with RepositoryFailure when findAll fails") void should_FailWithRepositoryFailure_When_FindAllFails() { when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findAll()).thenReturn(Result.failure(DB_ERROR)); + when(batchRepository.findAllSummary()).thenReturn(Result.failure(DB_ERROR)); var result = listBatches.execute(performedBy); @@ -135,7 +135,7 @@ class ListBatchesTest { void should_ReturnBatches_FilteredByStatus() { var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED)); when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(batches)); + when(batchRepository.findByStatusSummary(BatchStatus.PLANNED)).thenReturn(Result.success(batches)); var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); @@ -147,7 +147,7 @@ class ListBatchesTest { @DisplayName("should return empty list when no batches match status") void should_ReturnEmptyList_When_NoBatchesMatchStatus() { when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(List.of())); + when(batchRepository.findByStatusSummary(BatchStatus.PLANNED)).thenReturn(Result.success(List.of())); var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); @@ -159,7 +159,7 @@ class ListBatchesTest { @DisplayName("should fail with RepositoryFailure when findByStatus fails") void should_FailWithRepositoryFailure_When_FindByStatusFails() { when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.failure(DB_ERROR)); + when(batchRepository.findByStatusSummary(BatchStatus.PLANNED)).thenReturn(Result.failure(DB_ERROR)); var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); @@ -189,7 +189,7 @@ class ListBatchesTest { void should_ReturnBatches_FilteredByProductionDate() { var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED)); when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(batches)); + when(batchRepository.findByProductionDateSummary(PRODUCTION_DATE)).thenReturn(Result.success(batches)); var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); @@ -201,7 +201,7 @@ class ListBatchesTest { @DisplayName("should return empty list when no batches match date") void should_ReturnEmptyList_When_NoBatchesMatchDate() { when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(List.of())); + when(batchRepository.findByProductionDateSummary(PRODUCTION_DATE)).thenReturn(Result.success(List.of())); var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); @@ -213,7 +213,7 @@ class ListBatchesTest { @DisplayName("should fail with RepositoryFailure when findByProductionDate fails") void should_FailWithRepositoryFailure_When_FindByProductionDateFails() { when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); - when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.failure(DB_ERROR)); + when(batchRepository.findByProductionDateSummary(PRODUCTION_DATE)).thenReturn(Result.failure(DB_ERROR)); var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); @@ -245,7 +245,7 @@ class ListBatchesTest { var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED)); when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe))); - when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(batches)); + when(batchRepository.findByRecipeIdsSummary(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(batches)); var result = listBatches.executeByArticleId("article-123", performedBy); @@ -272,7 +272,7 @@ class ListBatchesTest { var recipe = sampleRecipe("recipe-1"); when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe))); - when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(List.of())); + when(batchRepository.findByRecipeIdsSummary(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(List.of())); var result = listBatches.executeByArticleId("article-123", performedBy); @@ -299,7 +299,7 @@ class ListBatchesTest { var recipe = sampleRecipe("recipe-1"); when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe))); - when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.failure(DB_ERROR)); + when(batchRepository.findByRecipeIdsSummary(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.failure(DB_ERROR)); var result = listBatches.executeByArticleId("article-123", performedBy); diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 0000000..92e859d --- /dev/null +++ b/loadtest/README.md @@ -0,0 +1,87 @@ +# Lasttests (Gatling) + +Last- und Performance-Tests fuer Effigenix ERP auf Basis von [Gatling](https://gatling.io/). + +## Voraussetzungen + +- Java 21 +- Maven +- Docker oder Podman (fuer Testcontainers/PostgreSQL) +- Backend muss vorher gebaut sein (`mvn install` im Root oder `backend/`) + +## Backend bauen + +Das Loadtest-Modul bindet das Backend als Dependency ein und startet es embedded. +Daher muss das Backend-Artefakt im lokalen Maven-Repository vorhanden sein: + +```bash +cd backend +mvn clean install -DskipTests +``` + +## Lasttests starten + +```bash +cd loadtest +mvn gatling:test +``` + +Das startet automatisch: + +1. PostgreSQL via Testcontainers (Docker/Podman) +2. Spring Boot embedded mit Liquibase-Migrationen +3. Testdaten-Seeding (120 Artikel, 30 Lieferanten, 50 Kunden, 30 Rezepte, ~1500 Chargen, 100 Produktionsauftraege) +4. Gatling-Simulation mit ~2500 Requests + +## Lastprofil + +| Szenario | User | Repeat | ~Requests | +|---|---|---|---| +| Stammdaten CRUD | 15 | 15x | ~240 | +| Produktions-Workflow | 30 | 1x (Chain) | ~270 | +| Lagerverwaltung | 15 | 15x | ~240 | +| Produktion Read-Only | 50 | 15x | ~800 | +| Stammdaten Read-Only | 50 | 15x | ~800 | +| **Gesamt** | | | **~2350** | + +## Ergebnisse + +Nach dem Lauf liegt der HTML-Report unter: + +``` +loadtest/target/gatling//index.html +``` + +## Assertions + +- p95 < 500ms (global) +- p99 < 1000ms (global) +- Fehlerrate < 5% +- Einzeldatensatz-Reads: mean < 20ms +- Listen (wenig Daten): mean < 35ms +- Listen (viel Daten): mean < 75ms +- Write-Requests: mean < 50ms + +## Konfiguration + +- `src/test/resources/gatling.conf` - Gatling-Einstellungen (Charting, HTTP-Cache, Writer) +- `src/test/resources/logback-test.xml` - Log-Level waehrend der Tests + +## Aufbau + +``` +loadtest/ + src/test/java/de/effigenix/loadtest/ + infrastructure/ + LoadTestInfrastructure.java # Startet PostgreSQL + Spring Boot + LoadTestDataSeeder.java # Erzeugt realistische Testdaten via REST-API + scenario/ + AuthenticationScenario.java # Login-Chain + MasterDataScenario.java # Artikel, Lieferanten, Kunden + ProductionScenario.java # Rezepte, Chargen, Produktionsauftraege + InventoryScenario.java # Lagerorte, Bestaende + simulation/ + FullWorkloadSimulation.java # Haupt-Simulation (kombiniert alle Szenarien) + util/ + JsonBodyBuilder.java # JSON-Request-Body-Erzeugung +``` diff --git a/loadtest/pom.xml b/loadtest/pom.xml new file mode 100644 index 0000000..80933f6 --- /dev/null +++ b/loadtest/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + de.effigenix + effigenix-loadtest + 0.1.0-SNAPSHOT + jar + + Effigenix ERP Load Tests + Gatling-basierte Last- und Performance-Tests für Effigenix ERP + + + 21 + 21 + 21 + UTF-8 + + 3.14.9 + 4.21.0 + 1.20.4 + 3.2.2 + + + + + + io.gatling.highcharts + gatling-charts-highcharts + ${gatling.version} + test + + + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + + + de.effigenix + effigenix-erp + 0.1.0-SNAPSHOT + test + + + + + org.postgresql + postgresql + 42.7.2 + test + + + + + src/test/java + + + src/test/resources + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + + + io.gatling + gatling-maven-plugin + ${gatling-maven-plugin.version} + + de.effigenix.loadtest.simulation.FullWorkloadSimulation + true + + -Xmx1g + + + + + + diff --git a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java new file mode 100644 index 0000000..73af035 --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java @@ -0,0 +1,602 @@ +package de.effigenix.loadtest.infrastructure; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ConfigurableApplicationContext; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Erzeugt realistische Testdaten via REST-API gegen die laufende Applikation. + * Simuliert einen mittelgroßen Fleischerei-Betrieb mit Jahresproduktion. + * + * Datenmengen: + * 15 Kategorien, 120 Artikel, 30 Lieferanten, 50 Kunden, + * 8 Lagerorte, 30 Rezepte, ~1500 Chargen (5-Jahres-Produktion), + * 100 Produktionsaufträge + */ +public final class LoadTestDataSeeder { + + private static final String ADMIN_USER = "admin"; + private static final String ADMIN_PASS = "admin123"; + + private final String baseUrl; + private final HttpClient http; + private final ObjectMapper mapper; + + private String adminToken; + + // Gesammelte IDs für spätere Referenz in Szenarien + private final List categoryIds = new ArrayList<>(); + private final List articleIds = new ArrayList<>(); + private final List supplierIds = new ArrayList<>(); + private final List customerIds = new ArrayList<>(); + private final List storageLocationIds = new ArrayList<>(); + private final List recipeIds = new ArrayList<>(); + private final List batchIds = new ArrayList<>(); + private final List productionOrderIds = new ArrayList<>(); + + // Statische Felder für Zugriff aus Szenarien + private static List seededCategoryIds; + private static List seededArticleIds; + private static List seededSupplierIds; + private static List seededCustomerIds; + private static List seededStorageLocationIds; + private static List seededRecipeIds; + private static List seededBatchIds; + private static List seededProductionOrderIds; + + public LoadTestDataSeeder(ConfigurableApplicationContext appContext) { + int port = appContext.getEnvironment() + .getProperty("local.server.port", Integer.class, 8080); + this.baseUrl = "http://localhost:" + port; + this.http = HttpClient.newHttpClient(); + this.mapper = new ObjectMapper(); + } + + public void seed() { + try { + long start = System.currentTimeMillis(); + login(); + seedCategories(); + seedArticles(); + seedSuppliers(); + seedCustomers(); + seedStorageLocations(); + seedRecipes(); + seedBatchesForYear(); + seedProductionOrders(); + + // Statische Referenzen setzen + seededCategoryIds = List.copyOf(categoryIds); + seededArticleIds = List.copyOf(articleIds); + seededSupplierIds = List.copyOf(supplierIds); + seededCustomerIds = List.copyOf(customerIds); + seededStorageLocationIds = List.copyOf(storageLocationIds); + seededRecipeIds = List.copyOf(recipeIds); + seededBatchIds = List.copyOf(batchIds); + seededProductionOrderIds = List.copyOf(productionOrderIds); + + long duration = System.currentTimeMillis() - start; + System.out.printf( + "Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, " + + "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge%n", + duration, categoryIds.size(), articleIds.size(), supplierIds.size(), + customerIds.size(), storageLocationIds.size(), recipeIds.size(), + batchIds.size(), productionOrderIds.size()); + } catch (Exception e) { + throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", e); + } + } + + private void login() throws Exception { + String body = """ + {"username":"%s","password":"%s"}""".formatted(ADMIN_USER, ADMIN_PASS); + var response = post("/api/auth/login", body, null); + adminToken = mapper.readTree(response).get("accessToken").asText(); + } + + // ---- Kategorien (15) ---- + + private void seedCategories() throws Exception { + String[] categories = { + "Frischfleisch", "Wurstwaren", "Schinken", "Aufschnitt", + "Brühwurst", "Rohwurst", "Kochwurst", "Pasteten", + "Convenience", "Gewürze & Zutaten", "Verpackungsmaterial", + "Rohstoffe Schwein", "Rohstoffe Rind", "Geflügel", "Feinkost" + }; + for (String name : categories) { + String body = """ + {"name":"%s","description":"Kategorie %s"}""".formatted(name, name); + var json = mapper.readTree(post("/api/categories", body, adminToken)); + categoryIds.add(json.get("id").asText()); + } + } + + // ---- Artikel (120) ---- + + private int articleSeq = 1; + + private void seedArticles() throws Exception { + // Eigene Produkte (Wurstwaren, Schinken, Aufschnitt, Brühwurst etc.) + String[][] eigenprodukte = { + {"Bratwurst grob", "1", "KG", "WEIGHT_BASED", "8.90"}, + {"Bratwurst fein", "1", "KG", "WEIGHT_BASED", "9.20"}, + {"Nürnberger Rostbratwurst", "1", "PIECE_FIXED", "FIXED", "4.50"}, + {"Currywurst", "1", "PIECE_FIXED", "FIXED", "3.90"}, + {"Wiener Würstchen", "4", "PIECE_FIXED", "FIXED", "3.50"}, + {"Bockwurst", "4", "PIECE_FIXED", "FIXED", "2.90"}, + {"Knackwurst", "4", "PIECE_FIXED", "FIXED", "3.20"}, + {"Weißwurst", "4", "PIECE_FIXED", "FIXED", "2.50"}, + {"Frankfurter Würstchen", "4", "PIECE_FIXED", "FIXED", "3.80"}, + {"Fleischwurst Ring", "4", "KG", "WEIGHT_BASED", "7.50"}, + {"Lyoner", "3", "KG", "WEIGHT_BASED", "8.20"}, + {"Bierschinken", "3", "KG", "WEIGHT_BASED", "8.50"}, + {"Jagdwurst", "3", "KG", "WEIGHT_BASED", "7.80"}, + {"Mortadella", "3", "KG", "WEIGHT_BASED", "9.20"}, + {"Schinkenwurst", "3", "KG", "WEIGHT_BASED", "9.50"}, + {"Salami Milano", "5", "KG", "WEIGHT_BASED", "22.50"}, + {"Salami Ungarn", "5", "KG", "WEIGHT_BASED", "19.80"}, + {"Cervelatwurst", "5", "KG", "WEIGHT_BASED", "18.90"}, + {"Mettwurst grob", "5", "KG", "WEIGHT_BASED", "10.50"}, + {"Mettwurst fein", "5", "KG", "WEIGHT_BASED", "11.20"}, + {"Teewurst", "5", "KG", "WEIGHT_BASED", "11.90"}, + {"Krakauer", "5", "KG", "WEIGHT_BASED", "9.80"}, + {"Paprikawurst", "5", "KG", "WEIGHT_BASED", "10.20"}, + {"Pfefferwurst", "5", "KG", "WEIGHT_BASED", "10.80"}, + {"Leberwurst fein", "6", "KG", "WEIGHT_BASED", "7.90"}, + {"Leberwurst grob", "6", "KG", "WEIGHT_BASED", "7.50"}, + {"Blutwurst", "6", "KG", "WEIGHT_BASED", "6.50"}, + {"Sülze", "6", "KG", "WEIGHT_BASED", "5.90"}, + {"Presssack", "6", "KG", "WEIGHT_BASED", "6.80"}, + {"Leberkäse", "7", "KG", "WEIGHT_BASED", "9.50"}, + {"Fleischkäse Pikant", "7", "KG", "WEIGHT_BASED", "10.20"}, + {"Pastete Champignon", "7", "KG", "WEIGHT_BASED", "12.50"}, + {"Pastete Pfeffer", "7", "KG", "WEIGHT_BASED", "13.20"}, + {"Schinken gekocht", "2", "KG", "WEIGHT_BASED", "18.90"}, + {"Nussschinken", "2", "KG", "WEIGHT_BASED", "22.50"}, + {"Lachsschinken", "2", "KG", "WEIGHT_BASED", "28.90"}, + {"Schwarzwälder Schinken", "2", "KG", "WEIGHT_BASED", "26.50"}, + {"Räucherschinken", "2", "KG", "WEIGHT_BASED", "21.90"}, + {"Kasseler Nacken", "2", "KG", "WEIGHT_BASED", "14.50"}, + {"Kasseler Rücken", "2", "KG", "WEIGHT_BASED", "15.90"}, + {"Fleischsalat", "14", "KG", "WEIGHT_BASED", "8.90"}, + {"Wurstsalat", "14", "KG", "WEIGHT_BASED", "9.50"}, + {"Kartoffelsalat m. Fleischeinlage", "14", "KG", "WEIGHT_BASED", "7.50"}, + {"Gulaschsuppe", "8", "KG", "WEIGHT_BASED", "6.50"}, + {"Maultaschen", "8", "PIECE_FIXED", "FIXED", "5.90"}, + {"Frikadellen", "8", "PIECE_FIXED", "FIXED", "1.80"}, + {"Hackfleisch Schwein/Rind", "0", "KG", "WEIGHT_BASED", "8.90"}, + {"Hackfleisch Rind", "0", "KG", "WEIGHT_BASED", "11.50"}, + }; + + // Rohstoffe und Einkaufsartikel + String[][] rohstoffe = { + {"Schweinenacken", "11", "KG", "WEIGHT_BASED", "6.50"}, + {"Schweinebauch", "11", "KG", "WEIGHT_BASED", "5.20"}, + {"Schweinekeule", "11", "KG", "WEIGHT_BASED", "5.80"}, + {"Schweineschulter", "11", "KG", "WEIGHT_BASED", "5.50"}, + {"Schweinekopf", "11", "KG", "WEIGHT_BASED", "2.80"}, + {"Schweinerücken", "11", "KG", "WEIGHT_BASED", "7.20"}, + {"Schweineleber", "11", "KG", "WEIGHT_BASED", "3.50"}, + {"Schweineblut", "11", "KG", "WEIGHT_BASED", "1.80"}, + {"Schweinefett/Speck", "11", "KG", "WEIGHT_BASED", "3.20"}, + {"Schwarte", "11", "KG", "WEIGHT_BASED", "2.50"}, + {"Rinderfilet", "12", "KG", "WEIGHT_BASED", "45.00"}, + {"Rindernuss", "12", "KG", "WEIGHT_BASED", "16.50"}, + {"Rinderkeule", "12", "KG", "WEIGHT_BASED", "14.20"}, + {"Rinderschulter", "12", "KG", "WEIGHT_BASED", "12.80"}, + {"Rinderbrust", "12", "KG", "WEIGHT_BASED", "11.50"}, + {"Gulasch Rind", "12", "KG", "WEIGHT_BASED", "16.50"}, + {"Gulasch Schwein", "11", "KG", "WEIGHT_BASED", "10.90"}, + {"Hähnchenbrustfilet", "13", "KG", "WEIGHT_BASED", "15.90"}, + {"Hähnchenschenkel", "13", "KG", "WEIGHT_BASED", "6.50"}, + {"Putenbrust", "13", "KG", "WEIGHT_BASED", "14.50"}, + {"Putenkeule", "13", "KG", "WEIGHT_BASED", "8.90"}, + {"Lammkeule", "0", "KG", "WEIGHT_BASED", "22.90"}, + {"Lammrücken", "0", "KG", "WEIGHT_BASED", "28.50"}, + {"Kalbsschnitzel", "0", "KG", "WEIGHT_BASED", "25.90"}, + // Gewürze & Zutaten + {"Nitritpökelsalz", "9", "KG", "WEIGHT_BASED", "2.50"}, + {"Pfeffer schwarz gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.20"}, + {"Pfeffer weiß gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.80"}, + {"Paprika edelsüß", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.80"}, + {"Paprika rosenscharf", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.10"}, + {"Knoblauch granuliert", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "4.50"}, + {"Knoblauch frisch", "9", "KG", "WEIGHT_BASED", "8.90"}, + {"Zwiebeln", "9", "KG", "WEIGHT_BASED", "1.20"}, + {"Majoran gerebelt", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.90"}, + {"Kümmel gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.90"}, + {"Kümmel ganz", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.50"}, + {"Senfkörner", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "1.80"}, + {"Muskatnuss gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "5.50"}, + {"Koriander gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.80"}, + {"Ingwer gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.20"}, + {"Zucker", "9", "KG", "WEIGHT_BASED", "1.50"}, + {"Dextrose", "9", "KG", "WEIGHT_BASED", "2.80"}, + {"Eiswasser", "9", "KG", "WEIGHT_BASED", "0.10"}, + {"Phosphat E450", "9", "KG", "WEIGHT_BASED", "4.50"}, + {"Ascorbinsäure E300", "9", "KG", "WEIGHT_BASED", "12.00"}, + {"Starterkulturen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "18.50"}, + {"Rauchsalz", "9", "KG", "WEIGHT_BASED", "5.50"}, + // Verpackung & Därme + {"Schweinedarm 28/30", "10", "PIECE_FIXED", "FIXED", "15.00"}, + {"Schweinedarm 32/34", "10", "PIECE_FIXED", "FIXED", "16.50"}, + {"Schafsdarm 22/24", "10", "PIECE_FIXED", "FIXED", "22.00"}, + {"Kunstdarm 45mm", "10", "PIECE_FIXED", "FIXED", "6.50"}, + {"Kunstdarm 60mm", "10", "PIECE_FIXED", "FIXED", "8.50"}, + {"Kunstdarm 90mm", "10", "PIECE_FIXED", "FIXED", "10.00"}, + {"Netz Schweinenetz", "10", "PIECE_FIXED", "FIXED", "3.50"}, + {"Vakuumbeutel 20x30", "10", "PIECE_FIXED", "FIXED", "0.15"}, + {"Vakuumbeutel 30x40", "10", "PIECE_FIXED", "FIXED", "0.22"}, + {"MAP-Schale 500g", "10", "PIECE_FIXED", "FIXED", "0.35"}, + {"MAP-Schale 250g", "10", "PIECE_FIXED", "FIXED", "0.28"}, + {"Etiketten Rolle 1000 St", "10", "PIECE_FIXED", "FIXED", "25.00"}, + // Frischfleisch zum Verkauf + {"Schnitzel Schwein", "0", "KG", "WEIGHT_BASED", "12.90"}, + {"Rouladen Rind", "0", "KG", "WEIGHT_BASED", "18.50"}, + {"Braten Schwein", "0", "KG", "WEIGHT_BASED", "11.50"}, + {"Spare Ribs", "0", "KG", "WEIGHT_BASED", "13.90"}, + {"Kotelett Schwein", "0", "KG", "WEIGHT_BASED", "10.90"}, + {"Filet Schwein", "0", "KG", "WEIGHT_BASED", "14.50"}, + {"Tafelspitz", "12", "KG", "WEIGHT_BASED", "19.90"}, + {"Suppenfleisch Rind", "12", "KG", "WEIGHT_BASED", "9.50"}, + {"Beinscheibe Rind", "12", "KG", "WEIGHT_BASED", "8.50"}, + {"Ochsenschwanz", "12", "KG", "WEIGHT_BASED", "12.50"}, + }; + + for (String[][] batch : List.of(eigenprodukte, rohstoffe)) { + for (String[] a : batch) { + String catId = categoryIds.get(Integer.parseInt(a[1]) % categoryIds.size()); + String num = "ART-%05d".formatted(articleSeq++); + String body = """ + {"name":"%s","articleNumber":"%s","categoryId":"%s","unit":"%s","priceModel":"%s","price":%s}""" + .formatted(a[0], num, catId, a[2], a[3], a[4]); + var json = mapper.readTree(post("/api/articles", body, adminToken)); + articleIds.add(json.get("id").asText()); + } + } + } + + // ---- Lieferanten (30) ---- + + private void seedSuppliers() throws Exception { + String[][] suppliers = { + {"Fleischgroßhandel Müller", "0711-1234567"}, + {"Gewürzkontor Hamburg", "040-9876543"}, + {"Darmfabrik Süd", "089-5551234"}, + {"Bio-Fleisch Bauer Schmidt", "07151-8889999"}, + {"Metzgereibedarf Weber", "0621-3334444"}, + {"Nordisch Fleisch GmbH", "040-1112233"}, + {"Schwäbische Landfleisch", "0711-5556677"}, + {"Kräuter & Gewürze Maier", "089-4445566"}, + {"Verpackung Express", "0621-7778899"}, + {"Kühl-Logistik Klein", "0711-2223344"}, + {"Rinderzucht Oberbayern", "08821-112233"}, + {"Geflügelhof Sonnental", "07156-445566"}, + {"Wurstgewürz Spezialitäten", "0911-778899"}, + {"Hygiene-Bedarf Reinhardt", "0621-990011"}, + {"Maschinenservice Fischer", "0711-667788"}, + {"Verpackungsmaterial Nord", "040-223344"}, + {"Lammfleisch Alpenland", "08821-556677"}, + {"Wild & Geflügel Jäger", "07531-889900"}, + {"Naturgewürze Bio", "089-112244"}, + {"Fleisch-Import Europa", "069-334455"}, + {"Schlachthof Reutlingen", "07121-556677"}, + {"Gewürzhaus Orient", "0711-998877"}, + {"Bayerische Fleischwerke", "089-665544"}, + {"Darmhandel International", "040-332211"}, + {"Fleischgroßmarkt Frankfurt", "069-112233"}, + {"Verpackungswerk Heilbronn", "07131-443322"}, + {"Salzhandel Südwest", "0711-887766"}, + {"Starter-Kulturen Wagner", "0621-554433"}, + {"Tiefkühl-Logistik Schwab", "07151-332211"}, + {"Reinigungsmittel Haas", "0711-776655"}, + }; + for (String[] s : suppliers) { + String body = """ + {"name":"%s","phone":"%s"}""".formatted(s[0], s[1]); + var json = mapper.readTree(post("/api/suppliers", body, adminToken)); + supplierIds.add(json.get("id").asText()); + } + } + + // ---- Kunden (50) ---- + + private void seedCustomers() throws Exception { + String[][] customers = { + // Gastronomie B2B + {"Restaurant Zum Goldenen Hirsch", "B2B", "Königstraße 12", "70173", "Stuttgart"}, + {"Gasthaus Linde", "B2B", "Hauptstraße 5", "70178", "Stuttgart"}, + {"Hotel Schwabenstube", "B2B", "Schlossplatz 8", "70173", "Stuttgart"}, + {"Pizzeria Bella Italia", "B2B", "Marienstraße 22", "70178", "Stuttgart"}, + {"Restaurant Ochsen", "B2B", "Calwer Str. 31", "70173", "Stuttgart"}, + {"Gasthof Adler", "B2B", "Tübinger Str. 44", "70178", "Stuttgart"}, + {"Restaurant Asia Garden", "B2B", "Eberhardstr. 10", "70173", "Stuttgart"}, + {"Steakhouse El Gaucho", "B2B", "Rotebühlplatz 1", "70178", "Stuttgart"}, + {"Weinwirtschaft Zur Kiste", "B2B", "Kanalstr. 2", "70182", "Stuttgart"}, + {"Brauerei Schönbuch", "B2B", "Böblinger Str. 101", "71032", "Böblingen"}, + {"Hotel Pullman", "B2B", "Heilbronner Str. 88", "70191", "Stuttgart"}, + {"Restaurant Zeppelin", "B2B", "Arnulf-Klett-Platz 7", "70173", "Stuttgart"}, + // Kantinen B2B + {"Betriebskantine Bosch", "B2B", "Robert-Bosch-Str. 1", "70839", "Gerlingen"}, + {"Betriebskantine Daimler", "B2B", "Mercedesstr. 120", "70372", "Stuttgart"}, + {"Betriebskantine Porsche", "B2B", "Porschestr. 42", "70435", "Stuttgart"}, + {"Uni-Mensa Stuttgart", "B2B", "Pfaffenwaldring 45", "70569", "Stuttgart"}, + {"Kantine Landratsamt", "B2B", "Löwentorstr. 54", "70376", "Stuttgart"}, + // Einzelhandel B2B + {"Metzgerei-Filiale Mitte", "B2B", "Marktplatz 3", "70173", "Stuttgart"}, + {"Metzgerei-Filiale Vaihingen", "B2B", "Vaihinger Markt 8", "70563", "Stuttgart"}, + {"Metzgerei-Filiale Degerloch", "B2B", "Epplestr. 12", "70597", "Stuttgart"}, + {"Metzgerei-Filiale Fellbach", "B2B", "Bahnhofstr. 24", "70734", "Fellbach"}, + {"Metzgerei-Filiale Esslingen", "B2B", "Pliensaustr. 6", "73728", "Esslingen"}, + {"Bio-Laden Sonnenschein", "B2B", "Gutenbergstr. 15", "70176", "Stuttgart"}, + {"Feinkost Böhm", "B2B", "Calwer Str. 58", "70173", "Stuttgart"}, + {"Reformhaus Vital", "B2B", "Schulstr. 3", "70173", "Stuttgart"}, + {"Hofladen Streuobstwiese", "B2B", "Gartenstr. 7", "70771", "Leinfelden"}, + {"Wochenmarkt Stuttgart", "B2B", "Schillerplatz", "70173", "Stuttgart"}, + // Großkunden B2B + {"Großmarkt Stuttgart", "B2B", "Heilbronner Str. 320", "70469", "Stuttgart"}, + {"Edeka Großhandel Süd", "B2B", "Hedelfinger Str. 55", "70327", "Stuttgart"}, + {"REWE Markt Stuttgart-Ost", "B2B", "Neckarstr. 190", "70190", "Stuttgart"}, + {"Tegut Stuttgart", "B2B", "Eberhardstr. 35", "70173", "Stuttgart"}, + {"Catering König", "B2B", "Industriestr. 22", "70565", "Stuttgart"}, + {"Event-Catering Schwab", "B2B", "Ulmer Str. 10", "70188", "Stuttgart"}, + {"Party-Service Müller", "B2B", "Pragstr. 44", "70376", "Stuttgart"}, + {"Essen auf Rädern Süd", "B2B", "Böblinger Str. 22", "70199", "Stuttgart"}, + {"Krankenhaus-Küche Olgahospital", "B2B", "Kriegsbergstr. 60", "70174", "Stuttgart"}, + {"Seniorenheim Am Park", "B2B", "Parkweg 5", "70192", "Stuttgart"}, + {"Kindergarten-Catering Sonnenkind", "B2B", "Rosensteinstr. 12", "70191", "Stuttgart"}, + {"Flughafen-Gastronomie STR", "B2B", "Flughafenstr. 43", "70629", "Stuttgart"}, + // Privatkunden B2C + {"Max Mustermann", "B2C", "Musterweg 1", "70173", "Stuttgart"}, + {"Erika Musterfrau", "B2C", "Beispielgasse 7", "70178", "Stuttgart"}, + {"Hans Wurstliebhaber", "B2C", "Fleischstr. 12", "70182", "Stuttgart"}, + {"Familie Schmid", "B2C", "Gartenweg 3", "70563", "Stuttgart"}, + {"Thomas Grillfan", "B2C", "Sonnenhalde 5", "70597", "Stuttgart"}, + {"Andrea Koch", "B2C", "Küchenstr. 8", "70376", "Stuttgart"}, + {"Peter Großeinkauf", "B2C", "Vorratsweg 22", "70435", "Stuttgart"}, + {"Susanne Feinschmecker", "B2C", "Delikatessenstr. 1", "70173", "Stuttgart"}, + {"Familie Weber", "B2C", "Am Weinberg 15", "70734", "Fellbach"}, + {"Familie Bauer", "B2C", "Lindenstr. 30", "73728", "Esslingen"}, + {"Verein SV Stuttgart 08", "B2C", "Sportplatzweg 1", "70469", "Stuttgart"}, + }; + int phoneSeq = 1000000; + for (String[] c : customers) { + String phone = "0711-%07d".formatted(phoneSeq++); + String body = """ + {"name":"%s","type":"%s","street":"%s","postalCode":"%s","city":"%s","country":"DE","phone":"%s"}""" + .formatted(c[0], c[1], c[2], c[3], c[4], phone); + var json = mapper.readTree(post("/api/customers", body, adminToken)); + customerIds.add(json.get("id").asText()); + } + } + + // ---- Lagerorte (8) ---- + + private void seedStorageLocations() throws Exception { + String[][] locations = { + {"Kühlhaus 1", "COLD_ROOM", "0", "4"}, + {"Kühlhaus 2", "COLD_ROOM", "0", "4"}, + {"Tiefkühllager", "FREEZER", "-22", "-18"}, + {"Tiefkühllager Reserven", "FREEZER", "-22", "-18"}, + {"Trockenlager", "DRY_STORAGE", null, null}, + {"Gewürzlager", "DRY_STORAGE", null, null}, + {"Theke Verkauf", "DISPLAY_COUNTER", "0", "7"}, + {"Produktionshalle", "PRODUCTION_AREA", "10", "18"}, + }; + for (String[] loc : locations) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"name\":\"").append(loc[0]).append("\",\"storageType\":\"").append(loc[1]).append("\""); + if (loc[2] != null) { + sb.append(",\"minTemperature\":\"").append(loc[2]).append("\""); + sb.append(",\"maxTemperature\":\"").append(loc[3]).append("\""); + } + sb.append("}"); + var json = mapper.readTree(post("/api/inventory/storage-locations", sb.toString(), adminToken)); + storageLocationIds.add(json.get("id").asText()); + } + } + + // ---- Rezepte (30) ---- + + private void seedRecipes() throws Exception { + String[][] recipes = { + // Brühwürste + {"Bratwurst grob", "FINISHED_PRODUCT", "88", "10", "10", "KILOGRAM"}, + {"Bratwurst fein", "FINISHED_PRODUCT", "90", "10", "10", "KILOGRAM"}, + {"Wiener Würstchen", "FINISHED_PRODUCT", "85", "14", "20", "KILOGRAM"}, + {"Bockwurst", "FINISHED_PRODUCT", "86", "14", "15", "KILOGRAM"}, + {"Fleischwurst Ring", "FINISHED_PRODUCT", "87", "10", "25", "KILOGRAM"}, + {"Lyoner", "FINISHED_PRODUCT", "88", "10", "20", "KILOGRAM"}, + {"Bierschinken", "FINISHED_PRODUCT", "82", "14", "15", "KILOGRAM"}, + {"Jagdwurst", "FINISHED_PRODUCT", "85", "10", "20", "KILOGRAM"}, + // Rohwürste + {"Salami Milano", "FINISHED_PRODUCT", "62", "28", "10", "KILOGRAM"}, + {"Cervelatwurst", "FINISHED_PRODUCT", "65", "21", "10", "KILOGRAM"}, + {"Mettwurst grob", "FINISHED_PRODUCT", "75", "14", "8", "KILOGRAM"}, + {"Teewurst", "FINISHED_PRODUCT", "78", "7", "5", "KILOGRAM"}, + // Kochwürste + {"Leberwurst fein", "FINISHED_PRODUCT", "80", "7", "10", "KILOGRAM"}, + {"Leberwurst grob", "FINISHED_PRODUCT", "82", "7", "10", "KILOGRAM"}, + {"Blutwurst", "FINISHED_PRODUCT", "85", "7", "10", "KILOGRAM"}, + {"Sülze", "FINISHED_PRODUCT", "90", "7", "15", "KILOGRAM"}, + // Schinken + {"Schinken gekocht", "FINISHED_PRODUCT", "72", "21", "12", "KILOGRAM"}, + {"Nussschinken", "FINISHED_PRODUCT", "68", "28", "10", "KILOGRAM"}, + {"Schwarzwälder Schinken", "FINISHED_PRODUCT", "55", "42", "8", "KILOGRAM"}, + {"Kasseler Nacken", "FINISHED_PRODUCT", "75", "14", "15", "KILOGRAM"}, + // Pasteten & Convenience + {"Leberkäse", "FINISHED_PRODUCT", "90", "3", "25", "KILOGRAM"}, + {"Fleischkäse Pikant", "FINISHED_PRODUCT", "88", "3", "20", "KILOGRAM"}, + {"Frikadellen", "FINISHED_PRODUCT", "92", "5", "30", "KILOGRAM"}, + {"Maultaschen", "FINISHED_PRODUCT", "85", "7", "20", "KILOGRAM"}, + // Halbfertigprodukte (Intermediate) + {"Brät fein", "INTERMEDIATE", "95", "1", "10", "KILOGRAM"}, + {"Brät grob", "INTERMEDIATE", "95", "1", "10", "KILOGRAM"}, + {"Gewürzmischung Bratwurst", "INTERMEDIATE", "100", "180", "2", "KILOGRAM"}, + {"Gewürzmischung Salami", "INTERMEDIATE", "100", "180", "2", "KILOGRAM"}, + {"Pökel-Lake Standard", "INTERMEDIATE", "100", "14", "50", "KILOGRAM"}, + {"Hackfleisch gewürzt", "INTERMEDIATE", "95", "1", "5", "KILOGRAM"}, + }; + + for (int i = 0; i < recipes.length; i++) { + String outputArticleId = articleIds.get(i % articleIds.size()); + String body = """ + {"name":"%s","version":1,"type":"%s","description":"Rezept für %s","yieldPercentage":%s,"shelfLifeDays":%s,"outputQuantity":"%s","outputUom":"%s","articleId":"%s"}""" + .formatted(recipes[i][0], recipes[i][1], recipes[i][0], + recipes[i][2], recipes[i][3], recipes[i][4], recipes[i][5], outputArticleId); + var json = mapper.readTree(post("/api/recipes", body, adminToken)); + String recipeId = json.get("id").asText(); + recipeIds.add(recipeId); + + // 3-5 Zutaten pro Rezept (Rohstoffe aus Artikel-Pool) + int ingredientCount = 3 + (i % 3); + int rohstoffStart = 48; // Index der Rohstoff-Artikel + for (int j = 0; j < ingredientCount; j++) { + String ingredientArticleId = articleIds.get(rohstoffStart + ((i * 5 + j) % (articleIds.size() - rohstoffStart))); + String qty = "%.1f".formatted(0.5 + (j + 1) * 0.8); + String ingredientBody = """ + {"position":%d,"articleId":"%s","quantity":"%s","uom":"KILOGRAM","substitutable":false}""" + .formatted(j + 1, ingredientArticleId, qty); + post("/api/recipes/" + recipeId + "/ingredients", ingredientBody, adminToken); + } + + // 2-4 Produktionsschritte + String[][] steps = { + {"Rohstoffe vorbereiten und wiegen", "15", "18"}, + {"Wolfen/Kuttern/Mischen", "30", "4"}, + {"Füllen und Abdrehen/Formen", "25", "12"}, + {"Brühen/Räuchern/Reifen", "60", "75"}, + }; + int stepCount = 2 + (i % 3); + for (int s = 0; s < stepCount && s < steps.length; s++) { + String stepBody = """ + {"stepNumber":%d,"description":"%s","durationMinutes":%s,"temperatureCelsius":%s}""" + .formatted(s + 1, steps[s][0], steps[s][1], steps[s][2]); + post("/api/recipes/" + recipeId + "/steps", stepBody, adminToken); + } + + // Rezept aktivieren + post("/api/recipes/" + recipeId + "/activate", "{}", adminToken); + } + } + + // ---- Chargen: 5 Jahre Produktion (~1500 Chargen) ---- + + private void seedBatchesForYear() throws Exception { + var today = LocalDate.now(); + var fiveYearsAgo = today.minusYears(5); + var rnd = ThreadLocalRandom.current(); + long totalDays = java.time.temporal.ChronoUnit.DAYS.between(fiveYearsAgo, today); + + // ~1500 Chargen über 5 Jahre verteilt (~1,2 pro Arbeitstag) + int batchCount = 1500; + int logged = 0; + for (int i = 0; i < batchCount; i++) { + LocalDate productionDate = fiveYearsAgo.plusDays(rnd.nextLong(totalDays)); + LocalDate bestBefore = productionDate.plusDays(rnd.nextInt(7, 90)); + + String recipeId = recipeIds.get(rnd.nextInt(recipeIds.size())); + int qty = rnd.nextInt(5, 50); + + String body = """ + {"recipeId":"%s","plannedQuantity":"%d","plannedQuantityUnit":"KILOGRAM","productionDate":"%s","bestBeforeDate":"%s"}""" + .formatted(recipeId, qty, productionDate, bestBefore); + + try { + var json = mapper.readTree(post("/api/production/batches", body, adminToken)); + String batchId = json.get("id").asText(); + batchIds.add(batchId); + + // 70% der Chargen starten + if (rnd.nextDouble() < 0.70) { + tryPost("/api/production/batches/" + batchId + "/start", "{}", adminToken); + } + } catch (Exception e) { + // Einzelne Fehler ignorieren (z.B. Duplikat-Batch-Nummern) + } + + // Fortschritt loggen + if ((i + 1) % 500 == 0) { + System.out.printf(" Chargen-Seeding: %d/%d ...%n", i + 1, batchCount); + } + } + } + + // ---- Produktionsaufträge (100, ab heute geplant) ---- + + private void seedProductionOrders() throws Exception { + var today = LocalDate.now(); + var rnd = ThreadLocalRandom.current(); + String[] priorities = {"NORMAL", "NORMAL", "NORMAL", "URGENT"}; + + for (int i = 0; i < 100; i++) { + LocalDate plannedDate = today.plusDays(rnd.nextInt(0, 90)); + String recipeId = recipeIds.get(rnd.nextInt(recipeIds.size())); + int qty = rnd.nextInt(10, 100); + String priority = priorities[rnd.nextInt(priorities.length)]; + + String body = """ + {"recipeId":"%s","plannedQuantity":"%d","plannedQuantityUnit":"KILOGRAM","plannedDate":"%s","priority":"%s","notes":"Seeded Lasttest-Auftrag #%d"}""" + .formatted(recipeId, qty, plannedDate, priority, i + 1); + + try { + var json = mapper.readTree(post("/api/production/production-orders", body, adminToken)); + String orderId = json.get("id").asText(); + productionOrderIds.add(orderId); + + // 40% der Aufträge sofort freigeben + if (rnd.nextDouble() < 0.40) { + tryPost("/api/production/production-orders/" + orderId + "/release", "{}", adminToken); + } + } catch (Exception e) { + // Einzelne Fehler ignorieren + } + } + } + + // ---- HTTP Helper ---- + + private String post(String path, String jsonBody, String token) throws Exception { + var builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + var response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new RuntimeException("HTTP %d auf %s: %s".formatted( + response.statusCode(), path, response.body())); + } + return response.body(); + } + + /** Post ohne Exception bei Fehler – für optionale Status-Übergänge. */ + private void tryPost(String path, String jsonBody, String token) { + try { + post(path, jsonBody, token); + } catch (Exception ignored) { + // Status-Übergang fehlgeschlagen, z.B. wegen Vorbedingungen + } + } + + // ---- Statische Getter für Szenarien ---- + + public static List categoryIds() { return seededCategoryIds; } + public static List articleIds() { return seededArticleIds; } + public static List supplierIds() { return seededSupplierIds; } + public static List customerIds() { return seededCustomerIds; } + public static List storageLocationIds() { return seededStorageLocationIds; } + public static List recipeIds() { return seededRecipeIds; } + public static List batchIds() { return seededBatchIds; } + public static List productionOrderIds() { return seededProductionOrderIds; } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java new file mode 100644 index 0000000..bfa1578 --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java @@ -0,0 +1,148 @@ +package de.effigenix.loadtest.infrastructure; + +import de.effigenix.EffigenixApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Startet die komplette Effigenix-Infrastruktur für Lasttests: + * 1. PostgreSQL via Testcontainers (echtes DB-Verhalten) + * 2. Spring Boot embedded mit Liquibase-Migrationen + * 3. Testdaten via LoadTestDataSeeder + */ +public final class LoadTestInfrastructure { + + private static final String POSTGRES_IMAGE = "postgres:16-alpine"; + private static final String DB_NAME = "effigenix_loadtest"; + private static final String DB_USER = "effigenix"; + private static final String DB_PASS = "effigenix"; + + private static PostgreSQLContainer postgres; + private static ConfigurableApplicationContext appContext; + private static int serverPort; + + private LoadTestInfrastructure() {} + + public static synchronized void start() { + if (appContext != null) { + return; + } + + configureDockerEnvironment(); + + postgres = new PostgreSQLContainer<>(POSTGRES_IMAGE) + .withDatabaseName(DB_NAME) + .withUsername(DB_USER) + .withPassword(DB_PASS); + postgres.start(); + + String jdbcUrl = postgres.getJdbcUrl(); + + // System-Properties setzen BEVOR Spring startet, damit sie die + // application.yml-Defaults (${DB_URL:...}) überschreiben. + System.setProperty("DB_URL", jdbcUrl); + System.setProperty("DB_USERNAME", DB_USER); + System.setProperty("DB_PASSWORD", DB_PASS); + System.setProperty("spring.datasource.url", jdbcUrl); + System.setProperty("spring.datasource.username", DB_USER); + System.setProperty("spring.datasource.password", DB_PASS); + System.setProperty("server.port", "0"); + + appContext = new SpringApplicationBuilder(EffigenixApplication.class) + .properties( + "server.port=0", + "spring.datasource.url=" + jdbcUrl, + "spring.datasource.username=" + DB_USER, + "spring.datasource.password=" + DB_PASS, + "spring.datasource.driver-class-name=org.postgresql.Driver", + "spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml", + "spring.liquibase.contexts=dev", + "jwt.secret=LoadTestSecretKeyMin256BitsLongForHS256AlgorithmSecurityTest", + "jwt.expiration=28800000", + "jwt.refresh-expiration=604800000", + "effigenix.cors.allowed-origins=*", + "logging.level.root=WARN", + "logging.level.de.effigenix=INFO", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "sentry.dsn=" + ) + .run(); + + serverPort = appContext.getEnvironment() + .getProperty("local.server.port", Integer.class, 8080); + + System.out.println("=== Effigenix ERP gestartet auf Port " + serverPort + " ==="); + System.out.println("=== PostgreSQL: " + jdbcUrl + " ==="); + + new LoadTestDataSeeder(appContext).seed(); + + System.out.println("=== Testdaten erzeugt ==="); + } + + /** + * Erkennt die Docker/Podman-Umgebung und konfiguriert Testcontainers entsprechend. + */ + private static void configureDockerEnvironment() { + if (System.getenv("DOCKER_HOST") != null) { + return; + } + if (Files.exists(Path.of("/var/run/docker.sock"))) { + return; + } + + // Podman rootless Socket + Path podmanSocket = Path.of("/run/user/" + getUid() + "/podman/podman.sock"); + if (Files.exists(podmanSocket)) { + System.setProperty("DOCKER_HOST", "unix://" + podmanSocket); + System.setProperty("TESTCONTAINERS_RYUK_DISABLED", "true"); + System.out.println("=== Podman erkannt: " + podmanSocket + " ==="); + return; + } + + // Podman root Socket + Path podmanRootSocket = Path.of("/run/podman/podman.sock"); + if (Files.exists(podmanRootSocket)) { + System.setProperty("DOCKER_HOST", "unix://" + podmanRootSocket); + System.setProperty("TESTCONTAINERS_RYUK_DISABLED", "true"); + System.out.println("=== Podman (root) erkannt: " + podmanRootSocket + " ==="); + } + } + + private static String getUid() { + try { + var process = new ProcessBuilder("id", "-u").start(); + return new String(process.getInputStream().readAllBytes()).trim(); + } catch (Exception e) { + return "1000"; + } + } + + public static String baseUrl() { + if (appContext == null) { + throw new IllegalStateException("LoadTestInfrastructure.start() muss zuerst aufgerufen werden"); + } + return "http://localhost:" + serverPort; + } + + public static int port() { + return serverPort; + } + + public static synchronized void stop() { + if (appContext != null) { + appContext.close(); + appContext = null; + } + if (postgres != null) { + postgres.stop(); + postgres = null; + } + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java new file mode 100644 index 0000000..1266704 --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java @@ -0,0 +1,64 @@ +package de.effigenix.loadtest.scenario; + +import de.effigenix.loadtest.util.JsonBodyBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; +import io.gatling.javaapi.http.HttpRequestActionBuilder; + +import static io.gatling.javaapi.core.CoreDsl.*; +import static io.gatling.javaapi.http.HttpDsl.*; + +/** + * Authentifizierungs-Szenario: Login → Token extrahieren → Token in Folge-Requests verwenden. + */ +public final class AuthenticationScenario { + + private AuthenticationScenario() {} + + /** + * Login-Request der ein JWT-Token extrahiert und in der Session speichert. + */ + public static HttpRequestActionBuilder login(String username, String password) { + return http("Login [" + username + "]") + .post("/api/auth/login") + .header("Content-Type", "application/json") + .body(StringBody(JsonBodyBuilder.loginBody(username, password))) + .check( + status().is(200), + jsonPath("$.accessToken").saveAs("accessToken"), + jsonPath("$.refreshToken").saveAs("refreshToken") + ); + } + + /** + * Token-Refresh-Request. + */ + public static HttpRequestActionBuilder refreshToken() { + return http("Token Refresh") + .post("/api/auth/refresh") + .header("Content-Type", "application/json") + .body(StringBody(""" + {"refreshToken":"#{refreshToken}"}""")) + .check( + status().is(200), + jsonPath("$.accessToken").saveAs("accessToken"), + jsonPath("$.refreshToken").saveAs("refreshToken") + ); + } + + /** + * Vollständiges Auth-Szenario: Login → Pause → Refresh → Pause → Logout. + */ + public static ScenarioBuilder authWorkflow() { + return scenario("Authentication Workflow") + .exec(login("admin", "admin123")) + .pause(1, 3) + .exec(refreshToken()) + .pause(1, 2) + .exec( + http("Logout") + .post("/api/auth/logout") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 204)) + ); + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java new file mode 100644 index 0000000..aba6fe9 --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java @@ -0,0 +1,88 @@ +package de.effigenix.loadtest.scenario; + +import de.effigenix.loadtest.infrastructure.LoadTestDataSeeder; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; + +import java.util.concurrent.ThreadLocalRandom; + +import static io.gatling.javaapi.core.CoreDsl.*; +import static io.gatling.javaapi.http.HttpDsl.*; + +/** + * Lagerverwaltungs-Szenario: Lagerorte und Bestände verwalten. + */ +public final class InventoryScenario { + + private InventoryScenario() {} + + public static ChainBuilder listStorageLocations() { + return exec( + http("Lagerorte auflisten") + .get("/api/inventory/storage-locations") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder getRandomStorageLocation() { + return exec(session -> { + var ids = LoadTestDataSeeder.storageLocationIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("storageLocationId", id); + }).exec( + http("Lagerort laden") + .get("/api/inventory/storage-locations/#{storageLocationId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder listStocks() { + return exec( + http("Bestände auflisten") + .get("/api/inventory/stocks") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder listStocksBelowMinimum() { + return exec( + http("Bestände unter Minimum") + .get("/api/inventory/stocks/below-minimum") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder listStocksByLocation() { + return exec(session -> { + var ids = LoadTestDataSeeder.storageLocationIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("filterStorageLocationId", id); + }).exec( + http("Bestände nach Lagerort") + .get("/api/inventory/stocks?storageLocationId=#{filterStorageLocationId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + /** + * Lagerverwaltungs-Workflow: Überwiegend Lese-Operationen. + */ + public static ScenarioBuilder inventoryWorkflow() { + return scenario("Lagerverwaltung") + .exec(AuthenticationScenario.login("admin", "admin123")) + .repeat(15).on( + randomSwitch().on( + percent(25.0).then(listStocks()), + percent(20.0).then(listStorageLocations()), + percent(20.0).then(getRandomStorageLocation()), + percent(20.0).then(listStocksByLocation()), + percent(15.0).then(listStocksBelowMinimum()) + ).pause(1, 3) + ); + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java new file mode 100644 index 0000000..2ba4425 --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java @@ -0,0 +1,204 @@ +package de.effigenix.loadtest.scenario; + +import de.effigenix.loadtest.infrastructure.LoadTestDataSeeder; +import de.effigenix.loadtest.util.JsonBodyBuilder; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; + +import java.util.concurrent.ThreadLocalRandom; + +import static io.gatling.javaapi.core.CoreDsl.*; +import static io.gatling.javaapi.http.HttpDsl.*; + +/** + * Stammdaten-Szenario: Artikel, Lieferanten, Kunden – 80% Read, 20% Write. + */ +public final class MasterDataScenario { + + private MasterDataScenario() {} + + // ---- Artikel ---- + + public static ChainBuilder listArticles() { + return exec( + http("Artikel auflisten") + .get("/api/articles") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder getRandomArticle() { + return exec(session -> { + var ids = LoadTestDataSeeder.articleIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("articleId", id); + }).exec( + http("Artikel laden") + .get("/api/articles/#{articleId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder createArticle() { + return exec(session -> { + var catIds = LoadTestDataSeeder.categoryIds(); + String catId = catIds.get(ThreadLocalRandom.current().nextInt(catIds.size())); + return session.set("newArticleBody", JsonBodyBuilder.createArticleBody(catId)); + }).exec( + http("Artikel anlegen") + .post("/api/articles") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("#{newArticleBody}")) + .check(status().in(200, 201, 400, 409, 500)) + .check(jsonPath("$.id").optional().saveAs("createdArticleId")) + ); + } + + public static ChainBuilder updateArticle() { + return exec(session -> session.set("updateArticleBody", JsonBodyBuilder.updateArticleBody())) + .exec( + http("Artikel bearbeiten") + .put("/api/articles/#{createdArticleId}") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("#{updateArticleBody}")) + .check(status().is(200)) + ); + } + + // ---- Lieferanten ---- + + public static ChainBuilder listSuppliers() { + return exec( + http("Lieferanten auflisten") + .get("/api/suppliers") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder getRandomSupplier() { + return exec(session -> { + var ids = LoadTestDataSeeder.supplierIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("supplierId", id); + }).exec( + http("Lieferant laden") + .get("/api/suppliers/#{supplierId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder createSupplier() { + return exec(session -> session.set("newSupplierBody", JsonBodyBuilder.createSupplierBody())) + .exec( + http("Lieferant anlegen") + .post("/api/suppliers") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("#{newSupplierBody}")) + .check(status().in(200, 201, 400, 409, 500)) + ); + } + + // ---- Kunden ---- + + public static ChainBuilder listCustomers() { + return exec( + http("Kunden auflisten") + .get("/api/customers") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder getRandomCustomer() { + return exec(session -> { + var ids = LoadTestDataSeeder.customerIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("customerId", id); + }).exec( + http("Kunde laden") + .get("/api/customers/#{customerId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder createCustomer() { + return exec(session -> session.set("newCustomerBody", JsonBodyBuilder.createCustomerBody())) + .exec( + http("Kunde anlegen") + .post("/api/customers") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("#{newCustomerBody}")) + .check(status().in(200, 201, 400, 409, 500)) + ); + } + + // ---- Kategorien ---- + + public static ChainBuilder listCategories() { + return exec( + http("Kategorien auflisten") + .get("/api/categories") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + // ---- Zusammengesetztes Szenario ---- + + /** + * Stammdaten-CRUD-Workflow mit 80/20 Read/Write-Verteilung. + */ + public static ScenarioBuilder masterDataWorkflow() { + return scenario("Stammdaten CRUD") + .exec(AuthenticationScenario.login("admin", "admin123")) + .repeat(15).on( + randomSwitch().on( + percent(20.0).then(listArticles()), + percent(15.0).then(getRandomArticle()), + percent(10.0).then(listSuppliers()), + percent(10.0).then(getRandomSupplier()), + percent(10.0).then(listCustomers()), + percent(10.0).then(getRandomCustomer()), + percent(5.0).then(listCategories()), + percent(5.0).then(createArticle()), + percent(5.0).then(createSupplier()), + percent(5.0).then(createCustomer()), + percent(5.0).then( + createArticle() + .pause(1) + .doIf(session -> session.contains("createdArticleId")).then( + exec(updateArticle()) + ) + ) + ).pause(1, 3) + ); + } + + /** + * Read-Only-Stammdaten-Szenario für zusätzliche Leselast. + */ + public static ScenarioBuilder masterDataReadOnly() { + return scenario("Stammdaten Read-Only") + .exec(AuthenticationScenario.login("admin", "admin123")) + .repeat(15).on( + randomSwitch().on( + percent(25.0).then(listArticles()), + percent(20.0).then(getRandomArticle()), + percent(15.0).then(listSuppliers()), + percent(15.0).then(getRandomSupplier()), + percent(10.0).then(listCustomers()), + percent(10.0).then(getRandomCustomer()), + percent(5.0).then(listCategories()) + ).pause(1, 3) + ); + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java new file mode 100644 index 0000000..60a06bc --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java @@ -0,0 +1,165 @@ +package de.effigenix.loadtest.scenario; + +import de.effigenix.loadtest.infrastructure.LoadTestDataSeeder; +import de.effigenix.loadtest.util.JsonBodyBuilder; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; + +import java.util.concurrent.ThreadLocalRandom; + +import static io.gatling.javaapi.core.CoreDsl.*; +import static io.gatling.javaapi.http.HttpDsl.*; + +/** + * Produktions-Szenario: Rezepte, Chargen, Produktionsaufträge. + * Bildet den End-to-End-Workflow der Produktion ab. + */ +public final class ProductionScenario { + + private ProductionScenario() {} + + // ---- Rezepte ---- + + public static ChainBuilder listRecipes() { + return exec( + http("Rezepte auflisten") + .get("/api/recipes") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder getRandomRecipe() { + return exec(session -> { + var ids = LoadTestDataSeeder.recipeIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("recipeId", id); + }).exec( + http("Rezept laden") + .get("/api/recipes/#{recipeId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + // ---- Chargen (Batches) ---- + + public static ChainBuilder listBatches() { + return exec( + http("Chargen auflisten") + .get("/api/production/batches") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder planBatch() { + return exec(session -> { + var ids = LoadTestDataSeeder.recipeIds(); + String recipeId = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("planBatchBody", JsonBodyBuilder.planBatchBody(recipeId)); + }).exec( + http("Charge planen") + .post("/api/production/batches") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("#{planBatchBody}")) + .check(status().in(200, 201, 400, 409, 500)) + .check(jsonPath("$.id").optional().saveAs("batchId")) + ); + } + + public static ChainBuilder startBatch() { + return exec( + http("Charge starten") + .post("/api/production/batches/#{batchId}/start") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 400, 409, 500)) + ); + } + + public static ChainBuilder completeBatch() { + return exec( + http("Charge abschließen") + .post("/api/production/batches/#{batchId}/complete") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody(JsonBodyBuilder.completeBatchBody())) + .check(status().in(200, 400, 409, 500)) + ); + } + + // ---- Produktionsaufträge ---- + + public static ChainBuilder createProductionOrder() { + return exec(session -> { + var ids = LoadTestDataSeeder.recipeIds(); + String recipeId = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("prodOrderBody", JsonBodyBuilder.createProductionOrderBody(recipeId)); + }).exec( + http("Produktionsauftrag anlegen") + .post("/api/production/production-orders") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("#{prodOrderBody}")) + .check(status().in(200, 201, 400, 409, 500)) + .check(jsonPath("$.id").optional().saveAs("productionOrderId")) + ); + } + + public static ChainBuilder releaseProductionOrder() { + return exec( + http("Produktionsauftrag freigeben") + .post("/api/production/production-orders/#{productionOrderId}/release") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 400, 409, 500)) + ); + } + + // ---- Zusammengesetztes Szenario ---- + + /** + * Produktions-Workflow: Rezepte lesen → Charge planen → starten → abschließen. + */ + public static ScenarioBuilder productionWorkflow() { + return scenario("Produktions-Workflow") + .exec(AuthenticationScenario.login("admin", "admin123")) + .exec(listRecipes()) + .pause(1, 2) + .exec(getRandomRecipe()) + .pause(1, 2) + // Charge planen und durchlaufen (nur wenn planBatch erfolgreich) + .exec(planBatch()) + .pause(1, 2) + .doIf(session -> session.contains("batchId")).then( + exec(startBatch()) + .pause(2, 5) + .exec(completeBatch()) + ) + .pause(1, 2) + // Produktionsauftrag anlegen und freigeben + .exec(createProductionOrder()) + .pause(1, 2) + .doIf(session -> session.contains("productionOrderId")).then( + exec(releaseProductionOrder()) + ) + .pause(1, 2) + // Nochmal Chargen-Liste prüfen + .exec(listBatches()); + } + + /** + * Read-Only-Produktions-Szenario für lesende Benutzer. + */ + public static ScenarioBuilder productionReadOnly() { + return scenario("Produktion Read-Only") + .exec(AuthenticationScenario.login("admin", "admin123")) + .repeat(15).on( + randomSwitch().on( + percent(40.0).then(listRecipes()), + percent(30.0).then(getRandomRecipe()), + percent(30.0).then(listBatches()) + ).pause(1, 3) + ); + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java new file mode 100644 index 0000000..707c367 --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java @@ -0,0 +1,118 @@ +package de.effigenix.loadtest.simulation; + +import de.effigenix.loadtest.infrastructure.LoadTestInfrastructure; +import de.effigenix.loadtest.scenario.*; +import io.gatling.javaapi.core.Simulation; +import io.gatling.javaapi.http.HttpProtocolBuilder; + +import static io.gatling.javaapi.core.CoreDsl.*; +import static io.gatling.javaapi.http.HttpDsl.*; + +/** + * Haupt-Simulation: Gemischte Last über alle Szenarien. + * + * Lastprofil (simuliert ~2500 Requests ≈ komprimiertes Jahres-Volumen): + * - 15 Admin-User: Stammdaten-CRUD (ramp 30s) + * - 30 Produktions-Worker: Produktions-Workflow (ramp 60s, sustained) + * - 15 Lager-Arbeiter: Lagerverwaltung (ramp 30s) + * - 50 Read-Only-User: Listen durchblättern (ramp 60s) + * + * Assertions: + * - p95 < 500ms (global) + * - p99 < 1000ms (global) + * - Error-Rate < 5% + */ +public class FullWorkloadSimulation extends Simulation { + + // Infrastruktur muss vor der Feld-Initialisierung starten + static { + LoadTestInfrastructure.start(); + } + + @Override + public void after() { + LoadTestInfrastructure.stop(); + } + + HttpProtocolBuilder httpProtocol = http + .baseUrl(LoadTestInfrastructure.baseUrl()) + .acceptHeader("application/json") + .contentTypeHeader("application/json") + .shareConnections(); + + { + setUp( + // Admin-User: Stammdaten-CRUD + MasterDataScenario.masterDataWorkflow() + .injectOpen(rampUsers(15).during(30)), + + // Produktions-Worker: Vollständiger Produktions-Workflow + ProductionScenario.productionWorkflow() + .injectOpen( + rampUsers(10).during(30), + nothingFor(15), + rampUsers(10).during(30), + nothingFor(15), + rampUsers(10).during(30) + ), + + // Lager-Arbeiter: Lagerverwaltung + InventoryScenario.inventoryWorkflow() + .injectOpen(rampUsers(15).during(30)), + + // Read-Only-User: Listen durchblättern + ProductionScenario.productionReadOnly() + .injectOpen( + rampUsers(25).during(60), + nothingFor(15), + rampUsers(25).during(45) + ), + + // Zusätzliche Read-Only auf Stammdaten + MasterDataScenario.masterDataReadOnly() + .injectOpen( + nothingFor(15), + rampUsers(25).during(45), + nothingFor(15), + rampUsers(25).during(45) + ) + ).protocols(httpProtocol) + .assertions( + // Global: Login (BCrypt ~230ms) hebt den Schnitt + global().responseTime().percentile(95.0).lt(500), + global().responseTime().percentile(99.0).lt(1000), + global().failedRequests().percent().lt(5.0), + + // Login darf langsam sein (BCrypt strength 12) + details("Login [admin]").responseTime().mean().lt(350), + details("Login [admin]").responseTime().percentile(95.0).lt(500), + + // Einzeldatensatz-Reads: streng (mean < 20ms) + details("Rezept laden").responseTime().mean().lt(20), + details("Artikel laden").responseTime().mean().lt(20), + details("Lieferant laden").responseTime().mean().lt(20), + details("Kunde laden").responseTime().mean().lt(20), + + // Listen mit wenig Daten (< 30 Einträge): mean < 35ms + details("Rezepte auflisten").responseTime().mean().lt(35), + details("Lagerorte auflisten").responseTime().mean().lt(35), + details("Bestände auflisten").responseTime().mean().lt(35), + details("Bestände unter Minimum").responseTime().mean().lt(35), + details("Bestände nach Lagerort").responseTime().mean().lt(35), + details("Lieferanten auflisten").responseTime().mean().lt(35), + details("Kategorien auflisten").responseTime().mean().lt(35), + + // Listen mit viel Daten (50-300 Einträge): mean < 75ms + details("Chargen auflisten").responseTime().mean().lt(75), + details("Artikel auflisten").responseTime().mean().lt(75), + details("Kunden auflisten").responseTime().mean().lt(75), + + // Garantiert vorkommende Write-Requests: moderat (mean < 50ms) + details("Charge planen").responseTime().mean().lt(50), + details("Charge starten").responseTime().mean().lt(50), + details("Charge abschließen").responseTime().mean().lt(50), + details("Produktionsauftrag anlegen").responseTime().mean().lt(50), + details("Produktionsauftrag freigeben").responseTime().mean().lt(50) + ); + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java b/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java new file mode 100644 index 0000000..96602ff --- /dev/null +++ b/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java @@ -0,0 +1,97 @@ +package de.effigenix.loadtest.util; + +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Hilfsklasse zum Erzeugen von JSON-Request-Bodies mit eindeutigen Werten. + * Atomare Counter verhindern Unique-Constraint-Verletzungen bei parallelen Writes. + */ +public final class JsonBodyBuilder { + + private static final AtomicInteger ARTICLE_SEQ = new AtomicInteger(10000); + private static final AtomicInteger SUPPLIER_SEQ = new AtomicInteger(10000); + private static final AtomicInteger CUSTOMER_SEQ = new AtomicInteger(10000); + private static final AtomicInteger CATEGORY_SEQ = new AtomicInteger(10000); + + private JsonBodyBuilder() {} + + public static String loginBody(String username, String password) { + return """ + {"username":"%s","password":"%s"}""".formatted(username, password); + } + + public static String createArticleBody(String categoryId) { + int seq = ARTICLE_SEQ.getAndIncrement(); + return """ + {"name":"Lasttest-Artikel-%d","articleNumber":"LT-%05d","categoryId":"%s","unit":"KG","priceModel":"WEIGHT_BASED","price":9.99}""" + .formatted(seq, seq, categoryId); + } + + public static String updateArticleBody() { + return """ + {"name":"Aktualisiert-%d"}""".formatted(System.nanoTime() % 100000); + } + + public static String createSupplierBody() { + int seq = SUPPLIER_SEQ.getAndIncrement(); + return """ + {"name":"Lasttest-Lieferant-%d","phone":"0711-%07d"}""" + .formatted(seq, seq); + } + + public static String updateSupplierBody() { + return """ + {"name":"Aktualisiert-Lieferant-%d"}""".formatted(System.nanoTime() % 100000); + } + + public static String createCustomerBody() { + int seq = CUSTOMER_SEQ.getAndIncrement(); + return """ + {"name":"Lasttest-Kunde-%d","type":"B2B","street":"Teststr. %d","postalCode":"70173","city":"Stuttgart","country":"DE","phone":"0711-%07d"}""" + .formatted(seq, seq, seq); + } + + public static String updateCustomerBody() { + return """ + {"name":"Aktualisiert-Kunde-%d"}""".formatted(System.nanoTime() % 100000); + } + + public static String createCategoryBody() { + int seq = CATEGORY_SEQ.getAndIncrement(); + return """ + {"name":"Lasttest-Kategorie-%d","description":"Automatisch erzeugt"}""" + .formatted(seq); + } + + public static String createStorageLocationBody() { + return """ + {"name":"LT-Lager-%d","storageType":"DRY_STORAGE"}""" + .formatted(System.nanoTime() % 100000); + } + + public static String createStockBody(String articleId, String storageLocationId) { + return """ + {"articleId":"%s","storageLocationId":"%s","minimumLevelAmount":"10","minimumLevelUnit":"KG"}""" + .formatted(articleId, storageLocationId); + } + + public static String planBatchBody(String recipeId) { + var today = LocalDate.now(); + var bestBefore = today.plusDays(14); + return """ + {"recipeId":"%s","plannedQuantity":"10","plannedQuantityUnit":"KG","productionDate":"%s","bestBeforeDate":"%s"}""" + .formatted(recipeId, today, bestBefore); + } + + public static String completeBatchBody() { + return """ + {"actualQuantity":"9.5","actualQuantityUnit":"KG","waste":"0.5","wasteUnit":"KG","remarks":"Lasttest"}"""; + } + + public static String createProductionOrderBody(String recipeId) { + return """ + {"recipeId":"%s","plannedQuantity":"20","plannedQuantityUnit":"KG","plannedDate":"%s","priority":"NORMAL"}""" + .formatted(recipeId, LocalDate.now().plusDays(1)); + } +} diff --git a/loadtest/src/test/resources/gatling.conf b/loadtest/src/test/resources/gatling.conf new file mode 100644 index 0000000..315bac7 --- /dev/null +++ b/loadtest/src/test/resources/gatling.conf @@ -0,0 +1,19 @@ +gatling { + charting { + indicators { + lowerBound = 50 + higherBound = 250 + } + } + http { + fetchedCssCacheMaxCapacity = 0 + fetchedHtmlCacheMaxCapacity = 0 + warmUpUrl = "" + } + data { + writers = [console, file] + console { + writePeriod = 10 + } + } +} diff --git a/loadtest/src/test/resources/logback-test.xml b/loadtest/src/test/resources/logback-test.xml new file mode 100644 index 0000000..4d88a2b --- /dev/null +++ b/loadtest/src/test/resources/logback-test.xml @@ -0,0 +1,29 @@ + + + + + %d{HH:mm:ss.SSS} [%-5level] %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + +