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:
parent
8a9bf849a9
commit
11fb62383b
21 changed files with 1856 additions and 38 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
19
loadtest/src/test/resources/gatling.conf
Normal file
19
loadtest/src/test/resources/gatling.conf
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
loadtest/src/test/resources/logback-test.xml
Normal file
29
loadtest/src/test/resources/logback-test.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue