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

feat(loadtest): Gatling-Lasttests mit ~2500 Requests für komprimiertes Jahres-Volumen

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).
This commit is contained in:
Sebastian Frick 2026-02-24 21:44:16 +01:00
parent 8a9bf849a9
commit 11fb62383b
21 changed files with 1856 additions and 38 deletions

View file

@ -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<String> categoryIds = new ArrayList<>();
private final List<String> articleIds = new ArrayList<>();
private final List<String> supplierIds = new ArrayList<>();
private final List<String> customerIds = new ArrayList<>();
private final List<String> storageLocationIds = new ArrayList<>();
private final List<String> recipeIds = new ArrayList<>();
private final List<String> batchIds = new ArrayList<>();
private final List<String> productionOrderIds = new ArrayList<>();
// Statische Felder für Zugriff aus Szenarien
private static List<String> seededCategoryIds;
private static List<String> seededArticleIds;
private static List<String> seededSupplierIds;
private static List<String> seededCustomerIds;
private static List<String> seededStorageLocationIds;
private static List<String> seededRecipeIds;
private static List<String> seededBatchIds;
private static List<String> 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<String> categoryIds() { return seededCategoryIds; }
public static List<String> articleIds() { return seededArticleIds; }
public static List<String> supplierIds() { return seededSupplierIds; }
public static List<String> customerIds() { return seededCustomerIds; }
public static List<String> storageLocationIds() { return seededStorageLocationIds; }
public static List<String> recipeIds() { return seededRecipeIds; }
public static List<String> batchIds() { return seededBatchIds; }
public static List<String> productionOrderIds() { return seededProductionOrderIds; }
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
gatling {
charting {
indicators {
lowerBound = 50
higherBound = 250
}
}
http {
fetchedCssCacheMaxCapacity = 0
fetchedHtmlCacheMaxCapacity = 0
warmUpUrl = ""
}
data {
writers = [console, file]
console {
writePeriod = 10
}
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%-5level] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Gatling-eigene Logs: nur WARN -->
<logger name="io.gatling" level="WARN"/>
<!-- Testcontainers: nur INFO -->
<logger name="org.testcontainers" level="INFO"/>
<logger name="com.github.dockerjava" level="WARN"/>
<!-- Spring Boot: nur WARN (beim Hochfahren) -->
<logger name="org.springframework" level="WARN"/>
<logger name="org.hibernate" level="WARN"/>
<!-- Applikation: INFO -->
<logger name="de.effigenix" level="INFO"/>
<!-- Loadtest-Infrastruktur: INFO -->
<logger name="de.effigenix.loadtest" level="INFO"/>
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>