Erweiterungen

This commit is contained in:
2026-01-27 09:58:28 +01:00
parent 24c0d192dd
commit 5768a37c5e
10 changed files with 861 additions and 2135 deletions

View File

@@ -6,7 +6,7 @@
<groupId>de.assecutor.votianlt</groupId> <groupId>de.assecutor.votianlt</groupId>
<artifactId>votianlt</artifactId> <artifactId>votianlt</artifactId>
<version>0.8.4</version> <version>0.8.5</version>
<packaging>jar</packaging> <packaging>jar</packaging>

View File

@@ -2,6 +2,7 @@ package de.assecutor.votianlt.ai.service;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.service.JobStatisticsService; import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -47,40 +48,32 @@ public class AiStatisticsService {
public StatisticsResponse analyzeStatisticsQuery(String userQuery) { public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
log.info("Processing statistics query: {}", userQuery); log.info("Processing statistics query: {}", userQuery);
// Gather current statistics // Determine query type and prepare chart data (includes customer filter detection)
String statisticsContext = buildStatisticsContext();
// Determine query type and prepare chart data
QueryAnalysis analysis = analyzeQueryType(userQuery); QueryAnalysis analysis = analyzeQueryType(userQuery);
log.debug("Query analysis - Type: {}, Chart: {}", analysis.queryType, analysis.chartType); log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}",
analysis.queryType, analysis.chartType,
analysis.customerFilter != null ? analysis.customerFilter : "none",
analysis.statusFilter != null ? analysis.statusFilter : "none");
// Gather context (statistics or job list depending on query type)
String statisticsContext = buildStatisticsContext(analysis);
// Build prompt for LLM // Build prompt for LLM
String prompt = buildPrompt(userQuery, statisticsContext, analysis); String prompt = buildPrompt(userQuery, statisticsContext, analysis);
// System prompt for the statistics assistant // System prompt - different for list vs statistics queries
String systemPrompt = """ String systemPrompt = analysis.queryType.equals("list")
Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen. ? buildListSystemPrompt()
Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken. : buildStatisticsSystemPrompt();
WICHTIGE FORMATIERUNGSREGELN:
- Verwende KEINE Tabellen (keine | oder --- Zeichen)
- Die Daten werden bereits als interaktives Diagramm visualisiert
- Fasse die wichtigsten Erkenntnisse in Fließtext oder kurzen Aufzählungen zusammen
- Nenne konkrete Zahlen im Text, aber liste nicht alle Werte tabellarisch auf
Antworte auf Deutsch, präzise und freundlich.
Erkläre die Daten kurz und gib bei Bedarf Empfehlungen.
Halte die Antwort kompakt (max. 3-4 Sätze für einfache Fragen, mehr für komplexe).
""";
// Call LLM via direct REST client (like aimailassistant) // Call LLM via direct REST client (like aimailassistant)
String llmResponse = llmClient.chat(systemPrompt, prompt); String llmResponse = llmClient.chat(systemPrompt, prompt);
if (llmResponse != null) { if (llmResponse != null && !llmResponse.isBlank()) {
log.info("LLM response received, length: {} chars", llmResponse.length()); log.info("LLM response received, length: {} chars", llmResponse.length());
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData); return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
} else { } else {
log.warn("LLM returned null response, using fallback"); log.warn("LLM returned null or blank response, using fallback");
return new StatisticsResponse( return new StatisticsResponse(
buildFallbackResponse(analysis), buildFallbackResponse(analysis),
analysis.chartType, analysis.chartType,
@@ -92,45 +85,284 @@ public class AiStatisticsService {
private record QueryAnalysis( private record QueryAnalysis(
String queryType, String queryType,
String chartType, String chartType,
String chartData String chartData,
String customerFilter, // null = no filter, show all data
JobStatus statusFilter // null = no status filter
) {} ) {}
private String buildStatisticsSystemPrompt() {
return """
Du bist ein Statistik-Assistent für ein Logistikunternehmen.
WICHTIG - ANTWORTSTIL:
- Beantworte NUR die gestellte Frage - keine zusätzlichen Informationen!
- Keine allgemeinen Tipps, Empfehlungen oder weiterführende Hinweise
- Keine Vergleiche mit anderen Daten, außer explizit gefragt
- Kurz und präzise: maximal 2-3 Sätze
- Nenne die relevanten Zahlen direkt
DIAGRAMM-HINWEIS:
- Ein Diagramm wird AUTOMATISCH angezeigt - nicht erwähnen oder beschreiben
- Sage NIEMALS "Ich kann kein Diagramm zeichnen" oder ähnliches
FORMATIERUNG:
- Keine Tabellen
- Keine langen Aufzählungen
- Fließtext bevorzugen
Antworte auf Deutsch.
""";
}
private String buildListSystemPrompt() {
return """
Du bist ein Assistent für ein Logistikunternehmen.
WICHTIG - ANTWORTSTIL:
- Der Benutzer fragt nach einer Liste von Jobs/Aufträgen
- Die Job-Liste wird bereits als Daten angezeigt
- Fasse nur kurz zusammen, was gefunden wurde (z.B. "Es wurden X Jobs gefunden")
- Keine detaillierte Auflistung der Jobs nötig - die Daten sind bereits sichtbar
- Maximal 1-2 Sätze
KEIN DIAGRAMM:
- Es wird KEIN Diagramm angezeigt bei Listen-Anfragen
- Erwähne keine Diagramme
Antworte auf Deutsch.
""";
}
/**
* Detect user-specified chart type from the query.
* Returns null if no specific chart type was requested.
*/
private String detectUserChartTypePreference(String query) {
String lowerQuery = query.toLowerCase();
// Balkendiagramm / Bar Chart
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") ||
lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) {
return "bar";
}
// Tortendiagramm / Pie Chart
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") ||
lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") ||
lowerQuery.contains("kreisdiagramm")) {
return "pie";
}
// Donut / Ring Chart
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") ||
lowerQuery.contains("doughnut")) {
return "doughnut";
}
// Liniendiagramm / Line Chart
if (lowerQuery.contains("linie") || lowerQuery.contains("line") ||
lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") ||
lowerQuery.contains("graph")) {
return "line";
}
// Flächendiagramm / Area Chart
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") ||
lowerQuery.contains("flächendiagramm")) {
return "line"; // Line with fill=true
}
// Radar Chart
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") ||
lowerQuery.contains("spinne")) {
return "radar";
}
// Polararea Chart
if (lowerQuery.contains("polar")) {
return "polarArea";
}
return null; // No specific preference
}
private QueryAnalysis analyzeQueryType(String query) { private QueryAnalysis analyzeQueryType(String query) {
String lowerQuery = query.toLowerCase(); String lowerQuery = query.toLowerCase();
// Status-bezogene Anfragen // First, check if this is a LIST query (no chart needed)
if (lowerQuery.contains("status") || lowerQuery.contains("offen") || boolean isListQuery = isListQuery(lowerQuery);
lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") || log.debug("Is list query: {}", isListQuery);
lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) {
return new QueryAnalysis("status", "doughnut", buildStatusChartData()); // Check if user specified a chart type (only relevant for non-list queries)
String userChartType = isListQuery ? null : detectUserChartTypePreference(query);
log.debug("User chart type preference: {}", userChartType != null ? userChartType : "none");
// Check if user specified a customer filter
String customerFilter = detectCustomerFilter(query);
log.debug("Customer filter: {}", customerFilter != null ? customerFilter : "none (showing all data)");
// Check if user specified a status filter
JobStatus statusFilter = detectStatusFilter(lowerQuery);
log.debug("Status filter: {}", statusFilter != null ? statusFilter : "none");
// For list queries, return no chart
if (isListQuery) {
return new QueryAnalysis("list", null, null, customerFilter, statusFilter);
} }
// Determine query type and default chart type
String queryType;
String defaultChartType;
String chartData;
// Status-bezogene Anfragen (Statistik, nicht Liste)
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") ||
lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) ||
lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
queryType = "status";
defaultChartType = "doughnut";
chartData = buildStatusChartData(customerFilter);
}
// Umsatz-bezogene Anfragen // Umsatz-bezogene Anfragen
if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
lowerQuery.contains("kunde") || lowerQuery.contains("customer") ||
lowerQuery.contains("einnahmen")) { lowerQuery.contains("einnahmen")) {
return new QueryAnalysis("revenue", "bar", buildRevenueChartData()); queryType = "revenue";
defaultChartType = "bar";
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData();
} }
// Trend-bezogene Anfragen // Trend-bezogene Anfragen
if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") || lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") ||
lowerQuery.contains("verlauf")) { lowerQuery.contains("verlauf")) {
return new QueryAnalysis("trend", "line", buildTrendChartData()); queryType = "trend";
defaultChartType = "line";
chartData = buildTrendChartData(customerFilter);
} }
// Task-bezogene Anfragen // Task-bezogene Anfragen
if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
lowerQuery.contains("erledigt")) { lowerQuery.contains("erledigt")) {
return new QueryAnalysis("tasks", "doughnut", buildTaskChartData()); queryType = "tasks";
defaultChartType = "doughnut";
chartData = buildTaskChartData();
} }
// Allgemeine Übersicht // Allgemeine Übersicht
return new QueryAnalysis("overview", "bar", buildOverviewChartData()); else {
queryType = "overview";
defaultChartType = "bar";
chartData = buildOverviewChartData(customerFilter);
} }
private String buildStatusChartData() { // Use user's chart type preference if specified, otherwise use default
Map<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus(); String chartType = userChartType != null ? userChartType : defaultChartType;
return new QueryAnalysis(queryType, chartType, chartData, customerFilter, null);
}
/**
* Detect status filter from the query.
*/
private JobStatus detectStatusFilter(String lowerQuery) {
if (lowerQuery.contains("angelegt") || lowerQuery.contains("erstellt") || lowerQuery.contains("created")) {
return JobStatus.CREATED;
}
if (lowerQuery.contains("in bearbeitung") || lowerQuery.contains("in progress")) {
return JobStatus.IN_PROGRESS;
}
if (lowerQuery.contains("abholung geplant") || lowerQuery.contains("pickup scheduled")) {
return JobStatus.PICKUP_SCHEDULED;
}
if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
return JobStatus.PICKED_UP;
}
if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") || lowerQuery.contains("unterwegs")) {
return JobStatus.IN_TRANSIT;
}
if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) {
return JobStatus.DELIVERED;
}
if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
return JobStatus.COMPLETED;
}
if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") || lowerQuery.contains("abgebrochen")) {
return JobStatus.CANCELLED;
}
return null;
}
/**
* Detect if the query is asking for a list of jobs (not statistics).
*/
private boolean isListQuery(String lowerQuery) {
// Keywords that indicate a list/detail query (not statistics)
boolean hasListKeywords = lowerQuery.contains("zeige alle") ||
lowerQuery.contains("liste") ||
lowerQuery.contains("welche jobs") ||
lowerQuery.contains("alle jobs") ||
lowerQuery.contains("alle aufträge") ||
lowerQuery.contains("zeige die jobs") ||
lowerQuery.contains("zeige die aufträge") ||
lowerQuery.contains("zeig mir die") ||
lowerQuery.contains("gib mir die");
// Keywords that indicate statistics (override list detection)
boolean hasStatsKeywords = lowerQuery.contains("statistik") ||
lowerQuery.contains("diagramm") ||
lowerQuery.contains("chart") ||
lowerQuery.contains("verteilung") ||
lowerQuery.contains("wie viele") ||
lowerQuery.contains("anzahl") ||
lowerQuery.contains("zähle") ||
lowerQuery.contains("umsatz") ||
lowerQuery.contains("trend") ||
lowerQuery.contains("torte") ||
lowerQuery.contains("balken");
return hasListKeywords && !hasStatsKeywords;
}
/**
* Detect customer filter from the query.
* Returns the matching customer name or null if no filter detected.
*/
private String detectCustomerFilter(String query) {
String lowerQuery = query.toLowerCase();
// Keywords that indicate a customer filter
String[] filterIndicators = {
"für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ",
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
"nur ", "ausschließlich ", "speziell "
};
// Check if any indicator is present
boolean hasIndicator = false;
String foundIndicator = null;
for (String indicator : filterIndicators) {
int idx = lowerQuery.indexOf(indicator);
if (idx >= 0) {
hasIndicator = true;
foundIndicator = indicator;
break;
}
}
if (!hasIndicator) {
log.debug("detectCustomerFilter - No filter indicator found in: '{}'", query);
return null;
}
log.debug("detectCustomerFilter - Found indicator '{}' in query: '{}'", foundIndicator, query);
// Try to find a matching customer in the query
String matchedCustomer = statisticsService.findMatchingCustomer(query);
log.debug("detectCustomerFilter - Matched customer: '{}'", matchedCustomer);
return matchedCustomer;
}
private String buildStatusChartData(String customerFilter) {
Map<JobStatus, Long> statusCounts = customerFilter != null
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
: statisticsService.getJobCountsByStatus();
List<String> labels = new ArrayList<>(); List<String> labels = new ArrayList<>();
List<Long> data = new ArrayList<>(); List<Long> data = new ArrayList<>();
@@ -154,7 +386,8 @@ public class AiStatisticsService {
} }
} }
return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Aufträge"); String label = customerFilter != null ? "Aufträge (" + customerFilter + ")" : "Aufträge";
return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), label);
} }
private String buildRevenueChartData() { private String buildRevenueChartData() {
@@ -177,9 +410,30 @@ public class AiStatisticsService {
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)"); return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
} }
private String buildTrendChartData() { private String buildCustomerRevenueChartData(String customer) {
var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customer);
var totalRevenue = statisticsService.getTotalRevenueForCustomer(customer);
long totalJobs = statisticsService.getTotalJobCountForCustomer(customer);
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
List<Double> data = List.of(
(double) totalJobs,
(double) completed,
(double) inProgress,
totalRevenue.doubleValue() / 100 // Scale down for better visualization
);
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
return buildChartJsonDouble(labels, data, colors, customer);
}
private String buildTrendChartData(String customerFilter) {
int currentYear = Year.now().getValue(); int currentYear = Year.now().getValue();
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(currentYear); Map<Month, Long> monthlyData = customerFilter != null
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
: statisticsService.getMonthlyJobCounts(currentYear);
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"); "Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
@@ -189,11 +443,15 @@ public class AiStatisticsService {
data.add(monthlyData.getOrDefault(month, 0L)); data.add(monthlyData.getOrDefault(month, 0L));
} }
String datasetLabel = customerFilter != null
? String.format("%s - %d", customerFilter, currentYear)
: String.format("Aufträge %d", currentYear);
return String.format(""" return String.format("""
{ {
"labels": %s, "labels": %s,
"datasets": [{ "datasets": [{
"label": "Aufträge %d", "label": "%s",
"data": %s, "data": %s,
"borderColor": "#6366f1", "borderColor": "#6366f1",
"backgroundColor": "rgba(99, 102, 241, 0.15)", "backgroundColor": "rgba(99, 102, 241, 0.15)",
@@ -206,7 +464,7 @@ public class AiStatisticsService {
"fill": true "fill": true
}] }]
} }
""", toJsonArray(labels), currentYear, data); """, toJsonArray(labels), datasetLabel, data);
} }
private String buildTaskChartData() { private String buildTaskChartData() {
@@ -222,10 +480,14 @@ public class AiStatisticsService {
return buildChartJson(labels, data, colors, "Aufgaben"); return buildChartJson(labels, data, colors, "Aufgaben");
} }
private String buildOverviewChartData() { private String buildOverviewChartData(String customerFilter) {
Map<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus(); Map<JobStatus, Long> statusCounts = customerFilter != null
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
: statisticsService.getJobCountsByStatus();
long total = statisticsService.getTotalJobCount(); long total = customerFilter != null
? statisticsService.getTotalJobCountForCustomer(customerFilter)
: statisticsService.getTotalJobCount();
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L); long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L); long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L);
@@ -234,7 +496,8 @@ public class AiStatisticsService {
List<Long> data = List.of(total, completed, inProgress, open); List<Long> data = List.of(total, completed, inProgress, open);
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4"); List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
return buildChartJson(labels, data, colors, "Aufträge"); String label = customerFilter != null ? customerFilter : "Aufträge";
return buildChartJson(labels, data, colors, label);
} }
private String buildChartJson(List<String> labels, List<Long> data, List<String> colors, String label) { private String buildChartJson(List<String> labels, List<Long> data, List<String> colors, String label) {
@@ -273,16 +536,43 @@ public class AiStatisticsService {
} }
} }
private String buildStatisticsContext() { private String buildStatisticsContext(QueryAnalysis analysis) {
StringBuilder context = new StringBuilder(); StringBuilder context = new StringBuilder();
String customerFilter = analysis.customerFilter;
JobStatus statusFilter = analysis.statusFilter;
// Job counts by status // For LIST queries, show actual job data
if ("list".equals(analysis.queryType)) {
return buildListContext(customerFilter, statusFilter);
}
// For statistics queries
if (customerFilter != null) {
// Filtered statistics for a specific customer
context.append(String.format("**Statistiken für Kunde: %s**\n\n", customerFilter));
var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customerFilter);
context.append("**Aufträge nach Status:**\n");
statusCounts.forEach((status, count) -> {
if (count > 0) {
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count));
}
});
context.append(String.format("\n**Übersicht für %s:**\n", customerFilter));
context.append(String.format("- Gesamtanzahl Aufträge: %d\n",
statisticsService.getTotalJobCountForCustomer(customerFilter)));
context.append(String.format("- Abschlussrate: %.1f%%\n",
statisticsService.getCompletionRateForCustomer(customerFilter)));
context.append(String.format("- Umsatz: %.2f EUR\n",
statisticsService.getTotalRevenueForCustomer(customerFilter)));
} else {
// General statistics (all data)
var statusCounts = statisticsService.getJobCountsByStatus(); var statusCounts = statisticsService.getJobCountsByStatus();
context.append("**Aktuelle Auftragsstatistiken:**\n"); context.append("**Aktuelle Auftragsstatistiken:**\n");
statusCounts.forEach((status, count) -> statusCounts.forEach((status, count) ->
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
// Totals
context.append(String.format("\n**Gesamtübersicht:**\n")); context.append(String.format("\n**Gesamtübersicht:**\n"));
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate())); context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
@@ -305,6 +595,45 @@ public class AiStatisticsService {
entry.getValue())); entry.getValue()));
} }
} }
}
return context.toString();
}
private String buildListContext(String customerFilter, JobStatus statusFilter) {
StringBuilder context = new StringBuilder();
List<Job> jobs;
if (customerFilter != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
context.append(String.format("**Jobs für %s mit Status %s:**\n\n",
customerFilter, statusFilter.getDisplayName()));
} else if (customerFilter != null) {
jobs = statisticsService.getJobsByCustomer(customerFilter);
context.append(String.format("**Jobs für %s:**\n\n", customerFilter));
} else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatus(statusFilter);
context.append(String.format("**Jobs mit Status %s:**\n\n", statusFilter.getDisplayName()));
} else {
jobs = statisticsService.getLatestJobs(20);
context.append("**Aktuelle Jobs:**\n\n");
}
context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size()));
// Show job summaries (max 10)
int shown = 0;
for (Job job : jobs) {
if (shown >= 10) {
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
break;
}
context.append(String.format("- %s: %s (%s)\n",
job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
job.getStatus().getDisplayName()));
shown++;
}
return context.toString(); return context.toString();
} }
@@ -319,19 +648,55 @@ public class AiStatisticsService {
} }
private String buildFallbackResponse(QueryAnalysis analysis) { private String buildFallbackResponse(QueryAnalysis analysis) {
String customer = analysis.customerFilter;
JobStatus statusFilter = analysis.statusFilter;
return switch (analysis.queryType) { return switch (analysis.queryType) {
case "list" -> {
List<Job> jobs;
if (customer != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.",
jobs.size(), customer, statusFilter.getDisplayName());
} else if (customer != null) {
jobs = statisticsService.getJobsByCustomer(customer);
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
} else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatus(statusFilter);
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.",
jobs.size(), statusFilter.getDisplayName());
} else {
yield "Hier sind die aktuellen Jobs.";
}
}
case "status" -> { case "status" -> {
var counts = statisticsService.getJobCountsByStatus(); var counts = customer != null
StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n"); ? statisticsService.getJobCountsByStatusForCustomer(customer)
: statisticsService.getJobCountsByStatus();
String title = customer != null
? String.format("**Auftragsübersicht für %s:**\n\n", customer)
: "**Auftragsübersicht nach Status:**\n\n";
StringBuilder sb = new StringBuilder(title);
counts.forEach((status, count) -> { counts.forEach((status, count) -> {
if (count > 0) { if (count > 0) {
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count)); sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
} }
}); });
sb.append(String.format("\n**Gesamt:** %d Aufträge", statisticsService.getTotalJobCount())); long total = customer != null
? statisticsService.getTotalJobCountForCustomer(customer)
: statisticsService.getTotalJobCount();
sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
yield sb.toString(); yield sb.toString();
} }
case "revenue" -> { case "revenue" -> {
if (customer != null) {
var revenue = statisticsService.getTotalRevenueForCustomer(customer);
var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
yield String.format("**Umsatz für %s:**\n\n" +
"- **Gesamtumsatz:** %.2f EUR\n" +
"- **Aufträge:** %d",
customer, revenue, jobCount);
} else {
var topCustomers = statisticsService.getTopCustomersByRevenue(5); var topCustomers = statisticsService.getTopCustomersByRevenue(5);
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
int rank = 1; int rank = 1;
@@ -344,12 +709,18 @@ public class AiStatisticsService {
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
yield sb.toString(); yield sb.toString();
} }
}
case "trend" -> { case "trend" -> {
int year = Year.now().getValue(); int year = Year.now().getValue();
var monthly = statisticsService.getMonthlyJobCounts(year); var monthly = customer != null
? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
: statisticsService.getMonthlyJobCounts(year);
long total = monthly.values().stream().mapToLong(Long::longValue).sum(); long total = monthly.values().stream().mapToLong(Long::longValue).sum();
yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " + String title = customer != null
"Die Verteilung ist im Diagramm ersichtlich.", year, total); ? String.format("**Monatstrend %d für %s:**", year, customer)
: String.format("**Monatstrend %d:**", year);
yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " +
"Die Verteilung ist im Diagramm ersichtlich.", title, total);
} }
case "tasks" -> { case "tasks" -> {
var taskStats = statisticsService.getTaskCompletionStats(); var taskStats = statisticsService.getTaskCompletionStats();
@@ -363,6 +734,16 @@ public class AiStatisticsService {
total, completed, rate, taskStats.getOrDefault("pending", 0L)); total, completed, rate, taskStats.getOrDefault("pending", 0L));
} }
default -> { default -> {
if (customer != null) {
yield String.format("**Übersicht für %s:**\n\n" +
"- **Aufträge gesamt:** %d\n" +
"- **Abschlussrate:** %.1f%%\n" +
"- **Umsatz:** %.2f EUR",
customer,
statisticsService.getTotalJobCountForCustomer(customer),
statisticsService.getCompletionRateForCustomer(customer),
statisticsService.getTotalRevenueForCustomer(customer));
} else {
yield String.format("**Übersicht:**\n\n" + yield String.format("**Übersicht:**\n\n" +
"- **Aufträge gesamt:** %d\n" + "- **Aufträge gesamt:** %d\n" +
"- **Abschlussrate:** %.1f%%\n" + "- **Abschlussrate:** %.1f%%\n" +
@@ -371,6 +752,7 @@ public class AiStatisticsService {
statisticsService.getCompletionRate(), statisticsService.getCompletionRate(),
statisticsService.getTotalRevenue()); statisticsService.getTotalRevenue());
} }
}
}; };
} }
} }

View File

@@ -106,16 +106,23 @@ public class LlmRestClient {
} }
private String extractContent(String response) { private String extractContent(String response) {
if (response == null) { if (response == null || response.isBlank()) {
log.warn("LLM returned null or blank response");
return null; return null;
} }
try { try {
JsonNode root = objectMapper.readTree(response); JsonNode root = objectMapper.readTree(response);
JsonNode choices = root.path("choices"); JsonNode choices = root.path("choices");
if (choices.isArray() && !choices.isEmpty()) { if (choices.isArray() && !choices.isEmpty()) {
return choices.get(0).path("message").path("content").asText(); String content = choices.get(0).path("message").path("content").asText();
// asText() returns empty string for null/missing nodes - treat as null
if (content == null || content.isBlank()) {
log.warn("LLM response content is empty");
return null;
} }
log.warn("Unexpected response structure: {}", response); return content;
}
log.warn("Unexpected response structure (no choices): {}", response);
return null; return null;
} catch (Exception e) { } catch (Exception e) {
log.error("Error parsing LLM response: {}", e.getMessage()); log.error("Error parsing LLM response: {}", e.getMessage());

View File

@@ -262,7 +262,11 @@ public class StatisticsView extends VerticalLayout {
// Text Response // Text Response
Div textDiv = new Div(); Div textDiv = new Div();
textDiv.getStyle().set("margin-top", "var(--lumo-space-s)"); textDiv.getStyle().set("margin-top", "var(--lumo-space-s)");
textDiv.getElement().setProperty("innerHTML", formatMarkdown(response.textResponse())); String responseText = response.textResponse();
if (responseText == null || responseText.isBlank()) {
responseText = "*Die Statistikdaten wurden erfolgreich abgerufen und werden im Diagramm angezeigt.*";
}
textDiv.getElement().setProperty("innerHTML", formatMarkdown(responseText));
bubble.add(textDiv); bubble.add(textDiv);
// Chart wenn vorhanden // Chart wenn vorhanden
@@ -505,7 +509,7 @@ public class StatisticsView extends VerticalLayout {
} }
} }
"""; """;
case "doughnut", "pie" -> """ case "doughnut" -> """
{ {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -535,6 +539,118 @@ public class StatisticsView extends VerticalLayout {
} }
} }
"""; """;
case "pie" -> """
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
}
},
animation: {
animateRotate: true,
animateScale: true,
duration: 1000,
easing: 'easeOutQuart'
}
}
""";
case "radar" -> """
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
}
},
scales: {
r: {
beginAtZero: true,
grid: {
color: 'rgba(0,0,0,0.1)'
},
pointLabels: {
font: { size: 12 }
}
}
},
elements: {
line: {
borderWidth: 2
},
point: {
radius: 4,
hoverRadius: 6
}
},
animation: {
duration: 1000,
easing: 'easeOutQuart'
}
}
""";
case "polarArea" -> """
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
}
},
scales: {
r: {
beginAtZero: true,
grid: {
color: 'rgba(0,0,0,0.1)'
}
}
},
animation: {
animateRotate: true,
animateScale: true,
duration: 1000,
easing: 'easeOutQuart'
}
}
""";
default -> """ default -> """
{ {
responsive: true, responsive: true,

View File

@@ -65,6 +65,12 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
*/ */
List<Job> findByCustomerSelection(String customerSelection); List<Job> findByCustomerSelection(String customerSelection);
/**
* Findet Aufträge anhand einer Kundenauswahl (case-insensitive)
*/
@Query("{ 'customerSelection': { $regex: ?0, $options: 'i' } }")
List<Job> findByCustomerSelectionIgnoreCase(String customerSelection);
/** /**
* Prüft, ob eine Auftragsnummer bereits existiert * Prüft, ob eine Auftragsnummer bereits existiert
*/ */

View File

@@ -16,6 +16,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Service for job statistics and aggregations. * Service for job statistics and aggregations.
@@ -142,10 +143,29 @@ public class JobStatisticsService {
} }
/** /**
* Get jobs by customer selection. * Get jobs by customer selection (case-insensitive, flexible matching).
*/ */
public List<Job> getJobsByCustomer(String customer) { public List<Job> getJobsByCustomer(String customer) {
return jobRepository.findByCustomerSelection(customer); if (customer == null || customer.isBlank()) {
return List.of();
}
// Trim and escape regex special characters for MongoDB
String trimmedCustomer = customer.trim();
String escapedCustomer = trimmedCustomer.replaceAll("([.^$*+?()\\[\\]{}|\\\\])", "\\\\$1");
// First try exact match (with optional whitespace)
String exactRegex = "^\\s*" + escapedCustomer + "\\s*$";
List<Job> jobs = jobRepository.findByCustomerSelectionIgnoreCase(exactRegex);
log.debug("getJobsByCustomer('{}') - exact regex: '{}' - found {} jobs", customer, exactRegex, jobs.size());
// If no exact match, try partial match (customer name contains the search term)
if (jobs.isEmpty()) {
String containsRegex = ".*" + escapedCustomer + ".*";
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, jobs.size());
}
return jobs;
} }
/** /**
@@ -200,4 +220,227 @@ public class JobStatisticsService {
public List<Job> getLatestJobs(int limit) { public List<Job> getLatestJobs(int limit) {
return jobRepository.findLatestJobs().stream().limit(limit).toList(); return jobRepository.findLatestJobs().stream().limit(limit).toList();
} }
// ==================== Filtered Statistics Methods ====================
/**
* Get all available customer names for autocomplete/filtering.
*/
public List<String> getAllCustomerNames() {
return jobRepository.findAll().stream()
.map(Job::getCustomerSelection)
.filter(c -> c != null && !c.isBlank())
.distinct()
.sorted()
.toList();
}
/**
* Get job counts by status filtered by customer.
*/
public Map<JobStatus, Long> getJobCountsByStatusForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer);
Map<JobStatus, Long> counts = new EnumMap<>(JobStatus.class);
for (JobStatus status : JobStatus.values()) {
counts.put(status, 0L);
}
for (Job job : customerJobs) {
counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L);
}
return counts;
}
/**
* Get total job count for a customer.
*/
public long getTotalJobCountForCustomer(String customer) {
return getJobsByCustomer(customer).size();
}
/**
* Get total revenue for a customer.
*/
public BigDecimal getTotalRevenueForCustomer(String customer) {
return getJobsByCustomer(customer).stream()
.map(Job::getPrice)
.filter(price -> price != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* Get completion rate for a customer.
*/
public double getCompletionRateForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer);
if (customerJobs.isEmpty()) {
return 0.0;
}
long completed = customerJobs.stream()
.filter(j -> j.getStatus() == JobStatus.COMPLETED)
.count();
return (double) completed / customerJobs.size() * 100.0;
}
/**
* Get monthly job counts for a customer in a specific year.
*/
public Map<Month, Long> getMonthlyJobCountsForCustomer(int year, String customer) {
Map<Month, Long> monthlyCounts = new LinkedHashMap<>();
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
List<Job> customerJobs = getJobsByCustomer(customer).stream()
.filter(j -> j.getCreatedAt() != null
&& !j.getCreatedAt().isBefore(yearStart)
&& !j.getCreatedAt().isAfter(yearEnd))
.toList();
// Initialize all months with 0
for (Month month : Month.values()) {
monthlyCounts.put(month, 0L);
}
// Count jobs per month
for (Job job : customerJobs) {
Month month = job.getCreatedAt().getMonth();
monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L);
}
return monthlyCounts;
}
/**
* Get jobs filtered by customer and optionally by status.
*/
public List<Job> getJobsByCustomerAndStatus(String customer, JobStatus status) {
List<Job> customerJobs = getJobsByCustomer(customer);
if (status == null) {
return customerJobs;
}
return customerJobs.stream()
.filter(j -> j.getStatus() == status)
.toList();
}
/**
* Find best matching customer name from query (fuzzy matching).
*/
public String findMatchingCustomer(String query) {
if (query == null || query.isBlank()) {
return null;
}
String lowerQuery = query.toLowerCase();
List<String> allCustomers = getAllCustomerNames();
log.debug("findMatchingCustomer - Query: '{}', Available customers: {}", query, allCustomers);
// First: exact match (case insensitive)
for (String customer : allCustomers) {
if (customer.equalsIgnoreCase(query)) {
log.debug("findMatchingCustomer - Exact match found: '{}'", customer);
return customer;
}
}
// Second: check if query contains a customer name
for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase();
if (lowerQuery.contains(lowerCustomer)) {
log.debug("findMatchingCustomer - Query contains customer: '{}'", customer);
return customer;
}
}
// Third: extract potential customer name from query and check if it matches a customer
// This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
String extractedName = extractCustomerNameFromQuery(query);
if (extractedName != null) {
String lowerExtracted = extractedName.toLowerCase();
for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase();
// Check if customer name starts with the extracted name, or contains it
if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) {
log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, customer);
return customer;
}
}
}
// Fourth: word match (any significant word from the query matches customer name)
String[] queryWords = lowerQuery.split("\\s+");
for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase();
for (String word : queryWords) {
// Skip common words and short words
if (word.length() >= 4 && !isCommonWord(word) && lowerCustomer.contains(word)) {
log.debug("findMatchingCustomer - Word match found: '{}' in '{}'", word, customer);
return customer;
}
}
}
log.debug("findMatchingCustomer - No match found for query: '{}'", query);
return null;
}
/**
* Extract potential customer name from a query string.
* Looks for patterns like "firma X", "kunde X", "für X", etc.
*/
private String extractCustomerNameFromQuery(String query) {
String lowerQuery = query.toLowerCase();
// Patterns that typically precede a customer name
String[] patterns = {
"firma ", "kunde ", "kunden ", "unternehmen ",
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
"der firma ", "des kunden ", "bei "
};
for (String pattern : patterns) {
int idx = lowerQuery.indexOf(pattern);
if (idx >= 0) {
// Extract everything after the pattern
int startIdx = idx + pattern.length();
String afterPattern = query.substring(startIdx).trim();
// Remove common trailing words
afterPattern = removeTrailingCommonWords(afterPattern);
if (!afterPattern.isBlank()) {
log.debug("extractCustomerNameFromQuery - Found potential name: '{}'", afterPattern);
return afterPattern;
}
}
}
return null;
}
/**
* Remove common trailing words from extracted customer name.
*/
private String removeTrailingCommonWords(String text) {
String[] trailingPatterns = {
" an$", " anzeigen$", " zeigen$", " auflisten$", " liste$",
" status$", " mit status$", " die$", " der$", " das$"
};
String result = text;
for (String pattern : trailingPatterns) {
result = result.replaceAll("(?i)" + pattern, "").trim();
}
return result;
}
/**
* Check if a word is a common German/English word that should be ignored in matching.
*/
private boolean isCommonWord(String word) {
return Set.of(
"zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und",
"firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the"
).contains(word.toLowerCase());
}
} }

View File

@@ -1,601 +0,0 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import de.assecutor.votianlt.model.task.*;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for Job and Task JSON serialization according to JOB_JSON.md specification.
* See docs/JOB_JSON.md for the JSON structure documentation.
*/
@DisplayName("Job JSON Serialization Tests")
class JobJsonSerializationTest {
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
@Nested
@DisplayName("Job Serialization Tests")
class JobSerializationTests {
@Test
@DisplayName("should serialize job with all fields according to spec")
void shouldSerializeJobWithAllFields() throws JsonProcessingException {
// Given
Job job = createFullJob();
// When
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
// Then - verify all required fields according to JOB_JSON.md
assertThat(node.has("id")).isTrue();
assertThat(node.get("id").asText()).isNotEmpty();
assertThat(node.get("jobNumber").asText()).isEqualTo("JOB-2024-001");
assertThat(node.get("status").asText()).isEqualTo("IN_PROGRESS");
assertThat(node.has("createdAt")).isTrue();
assertThat(node.get("createdBy").asText()).isEqualTo("admin@example.com");
assertThat(node.get("draft").asBoolean()).isFalse();
// Pickup address
assertThat(node.get("pickupCompany").asText()).isEqualTo("Absender GmbH");
assertThat(node.get("pickupSalutation").asText()).isEqualTo("Herr");
assertThat(node.get("pickupFirstName").asText()).isEqualTo("Max");
assertThat(node.get("pickupLastName").asText()).isEqualTo("Mustermann");
assertThat(node.get("pickupPhone").asText()).isEqualTo("+49 123 456789");
assertThat(node.get("pickupStreet").asText()).isEqualTo("Musterstraße");
assertThat(node.get("pickupHouseNumber").asText()).isEqualTo("42");
assertThat(node.get("pickupAddressAddition").asText()).isEqualTo("2. OG");
assertThat(node.get("pickupZip").asText()).isEqualTo("12345");
assertThat(node.get("pickupCity").asText()).isEqualTo("Musterstadt");
// Delivery address
assertThat(node.get("deliveryCompany").asText()).isEqualTo("Empfänger AG");
assertThat(node.get("deliverySalutation").asText()).isEqualTo("Frau");
assertThat(node.get("deliveryFirstName").asText()).isEqualTo("Erika");
assertThat(node.get("deliveryLastName").asText()).isEqualTo("Musterfrau");
assertThat(node.get("deliveryPhone").asText()).isEqualTo("+49 987 654321");
assertThat(node.get("deliveryStreet").asText()).isEqualTo("Beispielweg");
assertThat(node.get("deliveryHouseNumber").asText()).isEqualTo("7");
assertThat(node.get("deliveryZip").asText()).isEqualTo("54321");
assertThat(node.get("deliveryCity").asText()).isEqualTo("Beispielstadt");
// Digital processing
assertThat(node.get("digitalProcessing").asBoolean()).isTrue();
assertThat(node.get("appUser").asText()).isEqualTo("fahrer01");
// Dates
assertThat(node.has("pickupDate")).isTrue();
assertThat(node.has("deliveryDate")).isTrue();
// Other fields
assertThat(node.get("remark").asText()).isEqualTo("Bitte zwischen 9-12 Uhr liefern");
assertThat(node.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("150.00"));
}
@Test
@DisplayName("should serialize job id as string")
void shouldSerializeJobIdAsString() throws JsonProcessingException {
// Given
Job job = new Job();
ObjectId objectId = new ObjectId("507f1f77bcf86cd799439011");
job.setId(objectId);
job.setJobNumber("TEST-001");
job.setStatus(JobStatus.CREATED);
// When
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("id").asText()).isEqualTo("507f1f77bcf86cd799439011");
assertThat(node.get("id").isTextual()).isTrue();
}
@Test
@DisplayName("should serialize all job status values correctly")
void shouldSerializeAllJobStatusValues() throws JsonProcessingException {
// According to JOB_JSON.md: CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED
JobStatus[] expectedStatuses = {
JobStatus.CREATED,
JobStatus.IN_PROGRESS,
JobStatus.PICKUP_SCHEDULED,
JobStatus.PICKED_UP,
JobStatus.IN_TRANSIT,
JobStatus.DELIVERED,
JobStatus.COMPLETED,
JobStatus.CANCELLED
};
for (JobStatus status : expectedStatuses) {
Job job = new Job();
job.setId(new ObjectId());
job.setStatus(status);
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
assertThat(node.get("status").asText()).isEqualTo(status.name());
}
}
@Test
@DisplayName("should handle null optional fields gracefully")
void shouldHandleNullOptionalFieldsGracefully() throws JsonProcessingException {
// Given
Job job = new Job();
job.setId(new ObjectId());
job.setJobNumber("JOB-001");
job.setStatus(JobStatus.CREATED);
// All other fields are null
// When
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
// Then - should serialize without error
assertThat(node.get("jobNumber").asText()).isEqualTo("JOB-001");
assertThat(node.get("deliveryAddressAddition").isNull()).isTrue();
assertThat(node.get("remark").isNull()).isTrue();
assertThat(node.get("price").isNull()).isTrue();
}
}
@Nested
@DisplayName("Task Serialization Tests")
class TaskSerializationTests {
@Test
@DisplayName("should serialize ConfirmationTask according to spec")
void shouldSerializeConfirmationTask() throws JsonProcessingException {
// Given
ConfirmationTask task = new ConfirmationTask("Ware übernommen");
task.setId(new ObjectId("507f1f77bcf86cd799439012"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(1);
task.setDescription("Bitte bestätigen Sie die Übernahme der Ware");
task.setCompleted(false);
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("id").asText()).isEqualTo("507f1f77bcf86cd799439012");
assertThat(node.get("jobId").asText()).isEqualTo("507f1f77bcf86cd799439011");
assertThat(node.get("taskType").asText()).isEqualTo("CONFIRMATION");
assertThat(node.get("taskOrder").asInt()).isEqualTo(1);
assertThat(node.get("description").asText()).isEqualTo("Bitte bestätigen Sie die Übernahme der Ware");
assertThat(node.get("completed").asBoolean()).isFalse();
// taskSpecificData
JsonNode specificData = node.get("taskSpecificData");
assertThat(specificData).isNotNull();
assertThat(specificData.get("taskType").asText()).isEqualTo("CONFIRMATION");
assertThat(specificData.get("buttonText").asText()).isEqualTo("Ware übernommen");
}
@Test
@DisplayName("should serialize SignatureTask according to spec")
void shouldSerializeSignatureTask() throws JsonProcessingException {
// Given
SignatureTask task = new SignatureTask();
task.setId(new ObjectId("507f1f77bcf86cd799439013"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(2);
task.setDescription("Unterschrift des Empfängers");
task.setCompleted(false);
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("taskType").asText()).isEqualTo("SIGNATURE");
JsonNode specificData = node.get("taskSpecificData");
assertThat(specificData).isNotNull();
assertThat(specificData.get("taskType").asText()).isEqualTo("SIGNATURE");
}
@Test
@DisplayName("should serialize PhotoTask according to spec")
void shouldSerializePhotoTask() throws JsonProcessingException {
// Given
PhotoTask task = new PhotoTask(1, 5);
task.setId(new ObjectId("507f1f77bcf86cd799439014"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(3);
task.setDescription("Fotos der Ware bei Abholung");
task.setCompleted(false);
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("taskType").asText()).isEqualTo("PHOTO");
JsonNode specificData = node.get("taskSpecificData");
assertThat(specificData).isNotNull();
assertThat(specificData.get("taskType").asText()).isEqualTo("PHOTO");
assertThat(specificData.get("minPhotoCount").asInt()).isEqualTo(1);
assertThat(specificData.get("maxPhotoCount").asInt()).isEqualTo(5);
}
@Test
@DisplayName("should serialize BarcodeTask according to spec")
void shouldSerializeBarcodeTask() throws JsonProcessingException {
// Given
BarcodeTask task = new BarcodeTask(1, 10);
task.setId(new ObjectId("507f1f77bcf86cd799439015"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(4);
task.setDescription("Scannen Sie alle Pakete");
task.setCompleted(false);
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("taskType").asText()).isEqualTo("BARCODE");
JsonNode specificData = node.get("taskSpecificData");
assertThat(specificData).isNotNull();
assertThat(specificData.get("taskType").asText()).isEqualTo("BARCODE");
assertThat(specificData.get("minBarcodeCount").asInt()).isEqualTo(1);
assertThat(specificData.get("maxBarcodeCount").asInt()).isEqualTo(10);
}
@Test
@DisplayName("should serialize TodoListTask according to spec")
void shouldSerializeTodoListTask() throws JsonProcessingException {
// Given
List<String> todoItems = Arrays.asList(
"Verpackung auf Beschädigungen prüfen",
"Anzahl der Pakete kontrollieren",
"Lieferschein beiliegen"
);
TodoListTask task = new TodoListTask(todoItems);
task.setId(new ObjectId("507f1f77bcf86cd799439016"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(5);
task.setDescription("Checkliste vor Auslieferung");
task.setCompleted(false);
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("taskType").asText()).isEqualTo("TODOLIST");
JsonNode specificData = node.get("taskSpecificData");
assertThat(specificData).isNotNull();
assertThat(specificData.get("taskType").asText()).isEqualTo("TODOLIST");
assertThat(specificData.get("todoItems").isArray()).isTrue();
assertThat(specificData.get("todoItems").size()).isEqualTo(3);
assertThat(specificData.get("todoItems").get(0).asText()).isEqualTo("Verpackung auf Beschädigungen prüfen");
}
@Test
@DisplayName("should serialize CommentTask according to spec")
void shouldSerializeCommentTask() throws JsonProcessingException {
// Given
CommentTask task = new CommentTask(null, false);
task.setId(new ObjectId("507f1f77bcf86cd799439017"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(6);
task.setDescription("Anmerkungen zur Lieferung");
task.setCompleted(false);
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("taskType").asText()).isEqualTo("COMMENT");
JsonNode specificData = node.get("taskSpecificData");
assertThat(specificData).isNotNull();
assertThat(specificData.get("taskType").asText()).isEqualTo("COMMENT");
assertThat(specificData.get("commentText").isNull()).isTrue();
assertThat(specificData.get("required").asBoolean()).isFalse();
}
@Test
@DisplayName("should serialize completed task with completion data")
void shouldSerializeCompletedTaskWithCompletionData() throws JsonProcessingException {
// Given
ConfirmationTask task = new ConfirmationTask("Ware übernommen");
task.setId(new ObjectId("507f1f77bcf86cd799439012"));
task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
task.setTaskOrder(1);
task.setDescription("Ware übernommen bestätigen");
task.setCompleted(true);
task.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 15, 0));
task.setCompletedBy("fahrer01");
// When
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.get("completed").asBoolean()).isTrue();
assertThat(node.has("completedAt")).isTrue();
assertThat(node.get("completedBy").asText()).isEqualTo("fahrer01");
}
@Test
@DisplayName("should serialize all task types correctly")
void shouldSerializeAllTaskTypes() throws JsonProcessingException {
// According to JOB_JSON.md: CONFIRMATION, SIGNATURE, TODOLIST, PHOTO, BARCODE, COMMENT
BaseTask[] tasks = {
new ConfirmationTask("Test"),
new SignatureTask(),
new TodoListTask(List.of("Item")),
new PhotoTask(1, 3),
new BarcodeTask(1, 5),
new CommentTask(null, false)
};
String[] expectedTypes = {"CONFIRMATION", "SIGNATURE", "TODOLIST", "PHOTO", "BARCODE", "COMMENT"};
for (int i = 0; i < tasks.length; i++) {
tasks[i].setId(new ObjectId());
tasks[i].setJobId(new ObjectId());
String json = objectMapper.writeValueAsString(tasks[i]);
JsonNode node = objectMapper.readTree(json);
assertThat(node.get("taskType").asText())
.as("Task type for %s", tasks[i].getClass().getSimpleName())
.isEqualTo(expectedTypes[i]);
}
}
}
@Nested
@DisplayName("Job with Tasks Serialization Tests")
class JobWithTasksSerializationTests {
@Test
@DisplayName("should serialize complete job with tasks structure")
void shouldSerializeCompleteJobWithTasksStructure() throws JsonProcessingException {
// Given
Job job = createFullJob();
List<BaseTask> tasks = createAllTaskTypes(job.getId());
// Create wrapper object similar to the spec
record JobWithTasks(Job job, List<BaseTask> tasks) {}
JobWithTasks jobWithTasks = new JobWithTasks(job, tasks);
// When
String json = objectMapper.writeValueAsString(jobWithTasks);
JsonNode node = objectMapper.readTree(json);
// Then
assertThat(node.has("job")).isTrue();
assertThat(node.has("tasks")).isTrue();
assertThat(node.get("tasks").isArray()).isTrue();
assertThat(node.get("tasks").size()).isEqualTo(4);
// Verify task types are serialized correctly
JsonNode tasksNode = node.get("tasks");
assertThat(tasksNode.get(0).get("taskType").asText()).isEqualTo("CONFIRMATION");
assertThat(tasksNode.get(1).get("taskType").asText()).isEqualTo("PHOTO");
assertThat(tasksNode.get(2).get("taskType").asText()).isEqualTo("BARCODE");
assertThat(tasksNode.get(3).get("taskType").asText()).isEqualTo("SIGNATURE");
}
}
@Nested
@DisplayName("Field Type Tests")
class FieldTypeTests {
@Test
@DisplayName("should serialize ObjectId as String (not ObjectId type)")
void shouldSerializeObjectIdAsString() throws JsonProcessingException {
// According to spec: id is String (ObjectId) - should be serialized as string
Job job = new Job();
job.setId(new ObjectId("507f1f77bcf86cd799439011"));
job.setJobNumber("TEST");
job.setStatus(JobStatus.CREATED);
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
assertThat(node.get("id").isTextual()).isTrue();
assertThat(node.get("id").asText()).hasSize(24); // ObjectId hex length
}
@Test
@DisplayName("should serialize dates in ISO format")
void shouldSerializeDatesInIsoFormat() throws JsonProcessingException {
// According to spec: createdAt is ISO DateTime
Job job = new Job();
job.setId(new ObjectId());
job.setJobNumber("TEST");
job.setStatus(JobStatus.CREATED);
job.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
job.setPickupDate(LocalDate.of(2024, 1, 20));
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
assertThat(node.get("createdAt").asText()).isEqualTo("2024-01-15T10:30:00");
assertThat(node.get("pickupDate").asText()).isEqualTo("2024-01-20");
}
@Test
@DisplayName("should serialize price as decimal")
void shouldSerializePriceAsDecimal() throws JsonProcessingException {
// According to spec: price is Decimal
Job job = new Job();
job.setId(new ObjectId());
job.setJobNumber("TEST");
job.setStatus(JobStatus.CREATED);
job.setPrice(new BigDecimal("150.50"));
String json = objectMapper.writeValueAsString(job);
JsonNode node = objectMapper.readTree(json);
assertThat(node.get("price").isNumber()).isTrue();
assertThat(node.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("150.50"));
}
@Test
@DisplayName("should serialize taskOrder as Integer")
void shouldSerializeTaskOrderAsInteger() throws JsonProcessingException {
// According to spec: taskOrder is Integer
ConfirmationTask task = new ConfirmationTask("Test");
task.setId(new ObjectId());
task.setJobId(new ObjectId());
task.setTaskOrder(5);
String json = objectMapper.writeValueAsString(task);
JsonNode node = objectMapper.readTree(json);
assertThat(node.get("taskOrder").isInt()).isTrue();
assertThat(node.get("taskOrder").asInt()).isEqualTo(5);
}
@Test
@DisplayName("should serialize boolean fields correctly")
void shouldSerializeBooleanFieldsCorrectly() throws JsonProcessingException {
// According to spec: isDraft, completed are Boolean
Job job = new Job();
job.setId(new ObjectId());
job.setJobNumber("TEST");
job.setStatus(JobStatus.CREATED);
job.setDraft(true);
ConfirmationTask task = new ConfirmationTask("Test");
task.setId(new ObjectId());
task.setJobId(new ObjectId());
task.setCompleted(true);
String jobJson = objectMapper.writeValueAsString(job);
String taskJson = objectMapper.writeValueAsString(task);
JsonNode jobNode = objectMapper.readTree(jobJson);
JsonNode taskNode = objectMapper.readTree(taskJson);
assertThat(jobNode.get("draft").isBoolean()).isTrue();
assertThat(jobNode.get("draft").asBoolean()).isTrue();
assertThat(taskNode.get("completed").isBoolean()).isTrue();
assertThat(taskNode.get("completed").asBoolean()).isTrue();
}
}
// Helper methods
private Job createFullJob() {
Job job = new Job();
job.setId(new ObjectId("507f1f77bcf86cd799439011"));
job.setJobNumber("JOB-2024-001");
job.setStatus(JobStatus.IN_PROGRESS);
job.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
job.setUpdatedAt(LocalDateTime.of(2024, 1, 15, 14, 45, 0));
job.setCreatedBy("admin@example.com");
job.setDraft(false);
job.setCustomerSelection("Kunde01");
// Pickup address
job.setPickupCompany("Absender GmbH");
job.setPickupSalutation("Herr");
job.setPickupFirstName("Max");
job.setPickupLastName("Mustermann");
job.setPickupPhone("+49 123 456789");
job.setPickupStreet("Musterstraße");
job.setPickupHouseNumber("42");
job.setPickupAddressAddition("2. OG");
job.setPickupZip("12345");
job.setPickupCity("Musterstadt");
// Delivery address
job.setDeliveryCompany("Empfänger AG");
job.setDeliverySalutation("Frau");
job.setDeliveryFirstName("Erika");
job.setDeliveryLastName("Musterfrau");
job.setDeliveryPhone("+49 987 654321");
job.setDeliveryStreet("Beispielweg");
job.setDeliveryHouseNumber("7");
job.setDeliveryZip("54321");
job.setDeliveryCity("Beispielstadt");
// Digital processing
job.setDigitalProcessing(true);
job.setAppUser("fahrer01");
// Dates
job.setPickupDate(LocalDate.of(2024, 1, 20));
job.setDeliveryDate(LocalDate.of(2024, 1, 21));
// Other
job.setRemark("Bitte zwischen 9-12 Uhr liefern");
job.setPrice(new BigDecimal("150.00"));
return job;
}
private List<BaseTask> createAllTaskTypes(ObjectId jobId) {
ConfirmationTask confirmationTask = new ConfirmationTask("Ware übernommen");
confirmationTask.setId(new ObjectId("507f1f77bcf86cd799439012"));
confirmationTask.setJobId(jobId);
confirmationTask.setTaskOrder(1);
confirmationTask.setDescription("Ware übernommen bestätigen");
confirmationTask.setCompleted(true);
confirmationTask.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 15, 0));
confirmationTask.setCompletedBy("fahrer01");
PhotoTask photoTask = new PhotoTask(2, 5);
photoTask.setId(new ObjectId("507f1f77bcf86cd799439013"));
photoTask.setJobId(jobId);
photoTask.setTaskOrder(2);
photoTask.setDescription("Fotos bei Abholung");
photoTask.setCompleted(true);
photoTask.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 20, 0));
photoTask.setCompletedBy("fahrer01");
BarcodeTask barcodeTask = new BarcodeTask(1, 3);
barcodeTask.setId(new ObjectId("507f1f77bcf86cd799439014"));
barcodeTask.setJobId(jobId);
barcodeTask.setTaskOrder(3);
barcodeTask.setDescription("Pakete scannen");
barcodeTask.setCompleted(false);
SignatureTask signatureTask = new SignatureTask();
signatureTask.setId(new ObjectId("507f1f77bcf86cd799439015"));
signatureTask.setJobId(jobId);
signatureTask.setTaskOrder(4);
signatureTask.setDescription("Unterschrift Empfänger");
signatureTask.setCompleted(false);
return Arrays.asList(confirmationTask, photoTask, barcodeTask, signatureTask);
}
}

View File

@@ -1,489 +0,0 @@
package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.messaging.delivery.MessageDeliveryService;
import de.assecutor.votianlt.messaging.plugin.PluginException;
import de.assecutor.votianlt.messaging.plugin.PluginManager;
import de.assecutor.votianlt.messaging.plugin.SendOptions;
import de.assecutor.votianlt.service.ClientConnectionService.ClientState;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("ClientConnectionService Tests")
class ClientConnectionServiceTest {
@Mock
private PluginManager pluginManager;
@Mock
private MessageDeliveryService messageDeliveryService;
private ObjectMapper objectMapper;
private ClientConnectionService service;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
service = new ClientConnectionService(pluginManager, objectMapper, messageDeliveryService);
ReflectionTestUtils.setField(service, "pingIntervalSeconds", 15);
ReflectionTestUtils.setField(service, "pingTimeoutSeconds", 5);
}
@Nested
@DisplayName("registerClient Tests")
class RegisterClientTests {
@Test
@DisplayName("should register new client successfully")
void shouldRegisterNewClient() {
// When
service.registerClient("client-123", "user-456");
// Then
assertThat(service.isClientConnected("client-123")).isTrue();
assertThat(service.getConnectedClientCount()).isEqualTo(1);
}
@Test
@DisplayName("should not register client with null clientId")
void shouldNotRegisterNullClientId() {
// When
service.registerClient(null, "user-456");
// Then
assertThat(service.getConnectedClientCount()).isZero();
}
@Test
@DisplayName("should not register client with blank clientId")
void shouldNotRegisterBlankClientId() {
// When
service.registerClient(" ", "user-456");
// Then
assertThat(service.getConnectedClientCount()).isZero();
}
@Test
@DisplayName("should trigger retry for previously disconnected client")
void shouldTriggerRetryForReconnectedClient() {
// Given - register and mark as disconnected
service.registerClient("client-123", "user-456");
// Simulate disconnect by manipulating internal state
ClientState state = service.getClientState("client-123");
ClientState disconnectedState = state.withConnected(false);
ReflectionTestUtils.invokeMethod(service, "registerClient", "client-123", "user-456");
// First registration and re-registration
service.registerClient("client-123", "user-456");
// Then - verify the client is connected
assertThat(service.isClientConnected("client-123")).isTrue();
}
@Test
@DisplayName("should store client state correctly")
void shouldStoreClientStateCorrectly() {
// When
service.registerClient("client-123", "user-456");
// Then
ClientState state = service.getClientState("client-123");
assertThat(state).isNotNull();
assertThat(state.clientId()).isEqualTo("client-123");
assertThat(state.userId()).isEqualTo("user-456");
assertThat(state.connected()).isTrue();
assertThat(state.connectedAt()).isNotNull();
}
}
@Nested
@DisplayName("unregisterClient Tests")
class UnregisterClientTests {
@Test
@DisplayName("should unregister existing client")
void shouldUnregisterExistingClient() {
// Given
service.registerClient("client-123", "user-456");
// When
service.unregisterClient("client-123");
// Then
assertThat(service.isClientConnected("client-123")).isFalse();
assertThat(service.getClientState("client-123")).isNull();
assertThat(service.getConnectedClientCount()).isZero();
}
@Test
@DisplayName("should handle unregistering non-existent client gracefully")
void shouldHandleUnregisteringNonExistentClient() {
// When/Then - should not throw
service.unregisterClient("non-existent");
assertThat(service.getConnectedClientCount()).isZero();
}
}
@Nested
@DisplayName("handlePong Tests")
class HandlePongTests {
@Test
@DisplayName("should update client state on pong by clientId")
void shouldUpdateClientStateOnPongByClientId() {
// Given
service.registerClient("client-123", "user-456");
Instant beforePong = Instant.now();
// When
service.handlePong("client-123");
// Then
ClientState state = service.getClientState("client-123");
assertThat(state.lastPongReceived()).isAfterOrEqualTo(beforePong);
assertThat(state.connected()).isTrue();
}
@Test
@DisplayName("should update client state on pong by userId")
void shouldUpdateClientStateOnPongByUserId() {
// Given
service.registerClient("client-123", "user-456");
Instant beforePong = Instant.now();
// When
service.handlePong("user-456");
// Then
ClientState state = service.getClientState("client-123");
assertThat(state.lastPongReceived()).isAfterOrEqualTo(beforePong);
}
@Test
@DisplayName("should handle pong from unknown client gracefully")
void shouldHandlePongFromUnknownClient() {
// When/Then - should not throw
service.handlePong("unknown-client");
}
@Test
@DisplayName("should handle null pong id gracefully")
void shouldHandleNullPongId() {
// When/Then - should not throw
service.handlePong(null);
}
@Test
@DisplayName("should handle blank pong id gracefully")
void shouldHandleBlankPongId() {
// When/Then - should not throw
service.handlePong(" ");
}
@Test
@DisplayName("should trigger retry when disconnected client sends pong")
void shouldTriggerRetryWhenDisconnectedClientSendsPong() {
// Given - create a disconnected client state
service.registerClient("client-123", "user-456");
// Simulate disconnect
ClientState state = service.getClientState("client-123");
ClientState disconnectedState = state.withConnected(false);
// Use reflection to put the disconnected state
java.util.Map<String, ClientState> connectedClients =
(java.util.Map<String, ClientState>) ReflectionTestUtils.getField(service, "connectedClients");
connectedClients.put("client-123", disconnectedState);
// When
service.handlePong("client-123");
// Then
verify(messageDeliveryService).retryPendingDeliveriesForClient("client-123");
}
}
@Nested
@DisplayName("isClientConnected Tests")
class IsClientConnectedTests {
@Test
@DisplayName("should return true for connected client by clientId")
void shouldReturnTrueForConnectedClientByClientId() {
// Given
service.registerClient("client-123", "user-456");
// When/Then
assertThat(service.isClientConnected("client-123")).isTrue();
}
@Test
@DisplayName("should return true for connected client by userId")
void shouldReturnTrueForConnectedClientByUserId() {
// Given
service.registerClient("client-123", "user-456");
// When/Then
assertThat(service.isClientConnected("user-456")).isTrue();
}
@Test
@DisplayName("should return false for non-existent client")
void shouldReturnFalseForNonExistentClient() {
// When/Then
assertThat(service.isClientConnected("non-existent")).isFalse();
}
@Test
@DisplayName("should return false for null id")
void shouldReturnFalseForNullId() {
// When/Then
assertThat(service.isClientConnected(null)).isFalse();
}
@Test
@DisplayName("should return false for blank id")
void shouldReturnFalseForBlankId() {
// When/Then
assertThat(service.isClientConnected(" ")).isFalse();
}
}
@Nested
@DisplayName("getConnectedClientIds Tests")
class GetConnectedClientIdsTests {
@Test
@DisplayName("should return empty set when no clients connected")
void shouldReturnEmptySetWhenNoClients() {
// When
Set<String> ids = service.getConnectedClientIds();
// Then
assertThat(ids).isEmpty();
}
@Test
@DisplayName("should return all connected client ids")
void shouldReturnAllConnectedClientIds() {
// Given
service.registerClient("client-1", "user-1");
service.registerClient("client-2", "user-2");
service.registerClient("client-3", "user-3");
// When
Set<String> ids = service.getConnectedClientIds();
// Then
assertThat(ids).containsExactlyInAnyOrder("client-1", "client-2", "client-3");
}
@Test
@DisplayName("should not include disconnected clients")
void shouldNotIncludeDisconnectedClients() {
// Given
service.registerClient("client-1", "user-1");
service.registerClient("client-2", "user-2");
// Simulate disconnect for client-2
ClientState state = service.getClientState("client-2");
ClientState disconnectedState = state.withConnected(false);
java.util.Map<String, ClientState> connectedClients =
(java.util.Map<String, ClientState>) ReflectionTestUtils.getField(service, "connectedClients");
connectedClients.put("client-2", disconnectedState);
// When
Set<String> ids = service.getConnectedClientIds();
// Then
assertThat(ids).containsExactly("client-1");
}
}
@Nested
@DisplayName("sendPingsToAllClients Tests")
class SendPingsToAllClientsTests {
@Test
@DisplayName("should skip ping when plugin not connected")
void shouldSkipPingWhenPluginNotConnected() throws PluginException {
// Given
service.registerClient("client-123", "user-456");
when(pluginManager.isConnected()).thenReturn(false);
// When
service.sendPingsToAllClients();
// Then
verify(pluginManager, never()).sendToClient(anyString(), anyString(), any(), any());
}
@Test
@DisplayName("should skip ping when no clients connected")
void shouldSkipPingWhenNoClientsConnected() throws PluginException {
// Given
when(pluginManager.isConnected()).thenReturn(true);
// When
service.sendPingsToAllClients();
// Then
verify(pluginManager, never()).sendToClient(anyString(), anyString(), any(), any());
}
@Test
@DisplayName("should send ping to connected clients")
void shouldSendPingToConnectedClients() throws PluginException {
// Given
service.registerClient("client-123", "user-456");
when(pluginManager.isConnected()).thenReturn(true);
// When
service.sendPingsToAllClients();
// Then
verify(pluginManager).sendToClient(eq("user-456"), eq("ping"), any(byte[].class), any(SendOptions.class));
}
@Test
@DisplayName("should mark client as disconnected when ping times out")
void shouldMarkClientAsDisconnectedWhenPingTimesOut() {
// Given
service.registerClient("client-123", "user-456");
when(pluginManager.isConnected()).thenReturn(true);
// First ping
service.sendPingsToAllClients();
// Simulate time passing - set lastPingSent to past
ClientState state = service.getClientState("client-123");
Instant pastTime = Instant.now().minusSeconds(10);
ClientState stateWithOldPing = new ClientState(
state.clientId(),
state.userId(),
true,
pastTime,
null,
state.connectedAt()
);
java.util.Map<String, ClientState> connectedClients =
(java.util.Map<String, ClientState>) ReflectionTestUtils.getField(service, "connectedClients");
connectedClients.put("client-123", stateWithOldPing);
// When - second ping cycle
service.sendPingsToAllClients();
// Then
ClientState updatedState = service.getClientState("client-123");
assertThat(updatedState.connected()).isFalse();
}
}
@Nested
@DisplayName("ClientState Record Tests")
class ClientStateRecordTests {
@Test
@DisplayName("should create new state with ping sent")
void shouldCreateNewStateWithPingSent() {
// Given
Instant now = Instant.now();
ClientState state = new ClientState("client-1", "user-1", true, null, null, now);
// When
Instant pingSent = Instant.now();
ClientState newState = state.withPingSent(pingSent);
// Then
assertThat(newState.lastPingSent()).isEqualTo(pingSent);
assertThat(newState.clientId()).isEqualTo("client-1");
assertThat(newState.userId()).isEqualTo("user-1");
assertThat(newState.connected()).isTrue();
}
@Test
@DisplayName("should create new state with pong received")
void shouldCreateNewStateWithPongReceived() {
// Given
Instant now = Instant.now();
ClientState state = new ClientState("client-1", "user-1", false, null, null, now);
// When
Instant pongReceived = Instant.now();
ClientState newState = state.withPongReceived(pongReceived);
// Then
assertThat(newState.lastPongReceived()).isEqualTo(pongReceived);
assertThat(newState.connected()).isTrue(); // withPongReceived sets connected to true
}
@Test
@DisplayName("should create new state with connected flag")
void shouldCreateNewStateWithConnectedFlag() {
// Given
Instant now = Instant.now();
ClientState state = new ClientState("client-1", "user-1", true, null, null, now);
// When
ClientState newState = state.withConnected(false);
// Then
assertThat(newState.connected()).isFalse();
assertThat(newState.clientId()).isEqualTo("client-1");
}
}
@Nested
@DisplayName("Count Methods Tests")
class CountMethodsTests {
@Test
@DisplayName("should return correct connected client count")
void shouldReturnCorrectConnectedClientCount() {
// Given
service.registerClient("client-1", "user-1");
service.registerClient("client-2", "user-2");
// When/Then
assertThat(service.getConnectedClientCount()).isEqualTo(2);
}
@Test
@DisplayName("should return correct total client count")
void shouldReturnCorrectTotalClientCount() {
// Given
service.registerClient("client-1", "user-1");
service.registerClient("client-2", "user-2");
// Disconnect one client
ClientState state = service.getClientState("client-2");
ClientState disconnectedState = state.withConnected(false);
java.util.Map<String, ClientState> connectedClients =
(java.util.Map<String, ClientState>) ReflectionTestUtils.getField(service, "connectedClients");
connectedClients.put("client-2", disconnectedState);
// When/Then
assertThat(service.getConnectedClientCount()).isEqualTo(1);
assertThat(service.getTotalClientCount()).isEqualTo(2);
}
}
}

View File

@@ -1,402 +0,0 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.UserRepository;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("EmailService Tests")
class EmailServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private JobRepository jobRepository;
@Mock
private TaskRepository taskRepository;
@Mock
private JavaMailSender mailSender;
@InjectMocks
private EmailService emailService;
@Captor
private ArgumentCaptor<SimpleMailMessage> messageCaptor;
private static final String SMTP_USERNAME = "test@votianlt.de";
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(emailService, "smtpUsername", SMTP_USERNAME);
}
private User createTestUser() {
User user = new User();
user.setId(new ObjectId());
user.setTitle("Dr.");
user.setFirstname("Max");
user.setName("Mustermann");
user.setEmail("max.mustermann@example.com");
return user;
}
private Job createTestJob() {
Job job = new Job();
job.setId(new ObjectId());
job.setJobNumber("JOB-2024-001");
job.setDeliveryCompany("Test GmbH");
job.setPickupCity("Berlin");
job.setDeliveryCity("Hamburg");
job.setStatus(JobStatus.IN_PROGRESS);
job.setCreatedAt(LocalDateTime.now());
job.setCreatedBy(new ObjectId().toHexString());
return job;
}
@Nested
@DisplayName("sendTaskCompletionNotification Tests")
class SendTaskCompletionNotificationTests {
@Test
@DisplayName("should send email when job and user exist with valid email")
void shouldSendEmailWhenJobAndUserExist() {
// Given
Job job = createTestJob();
User user = createTestUser();
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender).send(messageCaptor.capture());
SimpleMailMessage sentMessage = messageCaptor.getValue();
assertThat(sentMessage.getFrom()).isEqualTo(SMTP_USERNAME);
assertThat(sentMessage.getTo()).containsExactly(user.getEmail());
assertThat(sentMessage.getSubject()).contains("Aufgabe abgeschlossen");
assertThat(sentMessage.getSubject()).contains(job.getJobNumber());
assertThat(sentMessage.getText()).contains("Dr. Max Mustermann");
assertThat(sentMessage.getText()).contains("Foto-Aufgabe");
}
@Test
@DisplayName("should not send email when job not found")
void shouldNotSendEmailWhenJobNotFound() {
// Given
ObjectId jobId = new ObjectId();
when(jobRepository.findById(jobId)).thenReturn(Optional.empty());
// When
emailService.sendTaskCompletionNotification(jobId, "PHOTO", "task-123", "app-user");
// Then
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
@DisplayName("should not send email when user not found")
void shouldNotSendEmailWhenUserNotFound() {
// Given
Job job = createTestJob();
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(any(ObjectId.class))).thenReturn(Optional.empty());
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
@DisplayName("should not send email when user has no email address")
void shouldNotSendEmailWhenUserHasNoEmail() {
// Given
Job job = createTestJob();
User user = createTestUser();
user.setEmail(null);
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
@DisplayName("should not send email when user has blank email address")
void shouldNotSendEmailWhenUserHasBlankEmail() {
// Given
Job job = createTestJob();
User user = createTestUser();
user.setEmail(" ");
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
@DisplayName("should handle different task types correctly")
void shouldHandleDifferentTaskTypes() {
// Given
Job job = createTestJob();
User user = createTestUser();
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// Test each task type
String[][] taskTypes = {
{"PHOTO", "Foto-Aufgabe"},
{"SIGNATURE", "Unterschrift"},
{"BARCODE", "Barcode scannen"},
{"CONFIRMATION", "Bestätigung"},
{"TODO_LIST", "Checkliste"},
{"COMMENT", "Kommentar"},
{"UNKNOWN_TYPE", "UNKNOWN_TYPE"}
};
for (String[] taskType : taskTypes) {
reset(mailSender);
// When
emailService.sendTaskCompletionNotification(job.getId(), taskType[0], "task-123", "app-user");
// Then
verify(mailSender).send(messageCaptor.capture());
assertThat(messageCaptor.getValue().getText()).contains(taskType[1]);
}
}
@Test
@DisplayName("should include route in email when cities are set")
void shouldIncludeRouteWhenCitiesAreSet() {
// Given
Job job = createTestJob();
User user = createTestUser();
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender).send(messageCaptor.capture());
String text = messageCaptor.getValue().getText();
assertThat(text).contains("Route: Berlin → Hamburg");
}
}
@Nested
@DisplayName("sendJobCompletionNotification Tests")
class SendJobCompletionNotificationTests {
@Test
@DisplayName("should send email with task count when job is completed")
void shouldSendEmailWithTaskCount() {
// Given
Job job = createTestJob();
User user = createTestUser();
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(Collections.emptyList());
// When
emailService.sendJobCompletionNotification(job.getId(), "app-user");
// Then
verify(mailSender).send(messageCaptor.capture());
SimpleMailMessage sentMessage = messageCaptor.getValue();
assertThat(sentMessage.getSubject()).contains("Job abgeschlossen");
assertThat(sentMessage.getText()).contains("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen");
assertThat(sentMessage.getText()).contains("Anzahl erledigter Aufgaben: 0");
}
@Test
@DisplayName("should not send email when job not found")
void shouldNotSendEmailWhenJobNotFound() {
// Given
ObjectId jobId = new ObjectId();
when(jobRepository.findById(jobId)).thenReturn(Optional.empty());
// When
emailService.sendJobCompletionNotification(jobId, "app-user");
// Then
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
}
@Nested
@DisplayName("sendJobCreationNotification Tests")
class SendJobCreationNotificationTests {
@Test
@DisplayName("should send email when job is created")
void shouldSendEmailWhenJobIsCreated() {
// Given
User user = createTestUser();
Job job = createTestJob();
job.setCreatedBy(user.getId().toHexString());
job.setRemark("Wichtige Lieferung");
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(Collections.emptyList());
// When
emailService.sendJobCreationNotification(job.getId(), user.getId().toHexString());
// Then
verify(mailSender).send(messageCaptor.capture());
SimpleMailMessage sentMessage = messageCaptor.getValue();
assertThat(sentMessage.getSubject()).contains("Neuer Job erstellt");
assertThat(sentMessage.getText()).contains("ein neuer Job wurde erfolgreich erstellt");
assertThat(sentMessage.getText()).contains("Bemerkung: Wichtige Lieferung");
}
@Test
@DisplayName("should handle invalid createdBy format")
void shouldHandleInvalidCreatedByFormat() {
// Given
Job job = createTestJob();
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
// When
emailService.sendJobCreationNotification(job.getId(), "invalid-object-id");
// Then
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
}
@Nested
@DisplayName("sendSimpleEmail Tests")
class SendSimpleEmailTests {
@Test
@DisplayName("should send simple email successfully")
void shouldSendSimpleEmail() {
// When
emailService.sendSimpleEmail("recipient@example.com", "Test Subject", "Test Body");
// Then
verify(mailSender).send(messageCaptor.capture());
SimpleMailMessage sentMessage = messageCaptor.getValue();
assertThat(sentMessage.getFrom()).isEqualTo(SMTP_USERNAME);
assertThat(sentMessage.getTo()).containsExactly("recipient@example.com");
assertThat(sentMessage.getSubject()).isEqualTo("Test Subject");
assertThat(sentMessage.getText()).isEqualTo("Test Body");
}
@Test
@DisplayName("should throw exception when mail sending fails")
void shouldThrowExceptionWhenMailSendingFails() {
// Given
doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
// When/Then
assertThatThrownBy(() ->
emailService.sendSimpleEmail("recipient@example.com", "Subject", "Body"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Failed to send email");
}
}
@Nested
@DisplayName("buildFullName Tests")
class BuildFullNameTests {
@Test
@DisplayName("should build full name with title, firstname and lastname")
void shouldBuildFullNameWithAllParts() {
// Given
Job job = createTestJob();
User user = createTestUser();
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender).send(messageCaptor.capture());
assertThat(messageCaptor.getValue().getText()).contains("Dr. Max Mustermann");
}
@Test
@DisplayName("should use default name when user has no name parts")
void shouldUseDefaultNameWhenNoNameParts() {
// Given
Job job = createTestJob();
User user = new User();
user.setId(new ObjectId());
user.setEmail("test@example.com");
job.setCreatedBy(user.getId().toHexString());
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
// When
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
// Then
verify(mailSender).send(messageCaptor.capture());
assertThat(messageCaptor.getValue().getText()).contains("Hallo Benutzer");
}
}
}

View File

@@ -1,536 +0,0 @@
package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobHistory;
import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.repository.JobHistoryRepository;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("JobHistoryService Tests")
class JobHistoryServiceTest {
@Mock
private JobHistoryRepository jobHistoryRepository;
private ObjectMapper objectMapper;
private JobHistoryService service;
@Captor
private ArgumentCaptor<JobHistory> historyCaptor;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
objectMapper.findAndRegisterModules();
service = new JobHistoryService(jobHistoryRepository, objectMapper);
}
private Job createTestJob() {
Job job = new Job();
job.setId(new ObjectId());
job.setJobNumber("JOB-2024-001");
job.setDeliveryCompany("Test GmbH");
job.setPickupCity("Berlin");
job.setDeliveryCity("Hamburg");
job.setStatus(JobStatus.CREATED);
job.setCreatedAt(LocalDateTime.now());
job.setRemark("Test Bemerkung");
return job;
}
@Nested
@DisplayName("logJobCreation Tests")
class LogJobCreationTests {
@Test
@DisplayName("should log job creation with job number")
void shouldLogJobCreationWithJobNumber() {
// Given
Job job = createTestJob();
String createdBy = "user-123";
// When
service.logJobCreation(job, createdBy);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getJobId()).isEqualTo(job.getId());
assertThat(history.getReason()).isEqualTo("Job erstellt");
assertThat(history.getDescription()).contains("JOB-2024-001");
assertThat(history.getChangedBy()).isEqualTo(createdBy);
assertThat(history.getChangeType()).isEqualTo(JobHistoryType.CREATE);
assertThat(history.getNewValue()).isEqualTo("Job erstellt");
}
@Test
@DisplayName("should log job creation without job number")
void shouldLogJobCreationWithoutJobNumber() {
// Given
Job job = createTestJob();
job.setJobNumber(null);
String createdBy = "user-123";
// When
service.logJobCreation(job, createdBy);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("Ohne Nummer");
}
@Test
@DisplayName("should include delivery company in details")
void shouldIncludeDeliveryCompanyInDetails() {
// Given
Job job = createTestJob();
String createdBy = "user-123";
// When
service.logJobCreation(job, createdBy);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDetails()).contains("Test GmbH");
}
@Test
@DisplayName("should handle exception gracefully")
void shouldHandleExceptionGracefully() {
// Given
Job job = createTestJob();
when(jobHistoryRepository.save(any())).thenThrow(new RuntimeException("DB error"));
// When/Then - should not throw
service.logJobCreation(job, "user-123");
verify(jobHistoryRepository).save(any());
}
}
@Nested
@DisplayName("logStatusChange Tests")
class LogStatusChangeTests {
@Test
@DisplayName("should log status change correctly")
void shouldLogStatusChangeCorrectly() {
// Given
Job job = createTestJob();
JobStatus oldStatus = JobStatus.CREATED;
JobStatus newStatus = JobStatus.IN_PROGRESS;
// When
service.logStatusChange(job, oldStatus, newStatus, "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getJobId()).isEqualTo(job.getId());
assertThat(history.getReason()).isEqualTo("Status-Änderung");
assertThat(history.getDescription()).contains("Erstellt");
assertThat(history.getDescription()).contains("In Bearbeitung");
assertThat(history.getChangeType()).isEqualTo(JobHistoryType.STATUS_CHANGE);
assertThat(history.getOldValue()).isEqualTo("Erstellt");
assertThat(history.getNewValue()).isEqualTo("In Bearbeitung");
}
@Test
@DisplayName("should handle null old status")
void shouldHandleNullOldStatus() {
// Given
Job job = createTestJob();
JobStatus newStatus = JobStatus.CREATED;
// When
service.logStatusChange(job, null, newStatus, "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("Unbekannt");
assertThat(history.getOldValue()).isNull();
}
@Test
@DisplayName("should handle null new status")
void shouldHandleNullNewStatus() {
// Given
Job job = createTestJob();
JobStatus oldStatus = JobStatus.IN_PROGRESS;
// When
service.logStatusChange(job, oldStatus, null, "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("Unbekannt");
assertThat(history.getNewValue()).isNull();
}
@Test
@DisplayName("should format all status types correctly")
void shouldFormatAllStatusTypesCorrectly() {
// Given
Job job = createTestJob();
JobStatus[] statuses = JobStatus.values();
for (JobStatus status : statuses) {
reset(jobHistoryRepository);
// When
service.logStatusChange(job, JobStatus.CREATED, status, "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
assertThat(historyCaptor.getValue().getNewValue()).isNotNull();
}
}
}
@Nested
@DisplayName("logJobUpdate Tests")
class LogJobUpdateTests {
@Test
@DisplayName("should log job update with reason")
void shouldLogJobUpdateWithReason() {
// Given
Job oldJob = createTestJob();
Job newJob = createTestJob();
newJob.setId(oldJob.getId());
newJob.setDeliveryCompany("Neue GmbH");
// When
service.logJobUpdate(oldJob, newJob, "user-123", "Kundendaten geändert");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getReason()).isEqualTo("Kundendaten geändert");
assertThat(history.getChangeType()).isEqualTo(JobHistoryType.UPDATE);
assertThat(history.getDescription()).contains("Kunde");
}
@Test
@DisplayName("should use default reason when null")
void shouldUseDefaultReasonWhenNull() {
// Given
Job oldJob = createTestJob();
Job newJob = createTestJob();
newJob.setId(oldJob.getId());
// When
service.logJobUpdate(oldJob, newJob, "user-123", null);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getReason()).isEqualTo("Job aktualisiert");
}
@Test
@DisplayName("should detect city changes")
void shouldDetectCityChanges() {
// Given
Job oldJob = createTestJob();
Job newJob = createTestJob();
newJob.setId(oldJob.getId());
newJob.setPickupCity("München");
// When
service.logJobUpdate(oldJob, newJob, "user-123", null);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("Orte");
}
@Test
@DisplayName("should detect remark changes")
void shouldDetectRemarkChanges() {
// Given
Job oldJob = createTestJob();
Job newJob = createTestJob();
newJob.setId(oldJob.getId());
newJob.setRemark("Neue Bemerkung");
// When
service.logJobUpdate(oldJob, newJob, "user-123", null);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("Bemerkung");
}
@Test
@DisplayName("should handle null old job")
void shouldHandleNullOldJob() {
// Given
Job newJob = createTestJob();
// When
service.logJobUpdate(null, newJob, "user-123", null);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).isEqualTo("Job-Daten aktualisiert");
}
}
@Nested
@DisplayName("logTaskCompletion Tests")
class LogTaskCompletionTests {
@Test
@DisplayName("should log task completion with basic info")
void shouldLogTaskCompletionWithBasicInfo() {
// Given
ObjectId jobId = new ObjectId();
// When
service.logTaskCompletion(jobId, "PHOTO", "task-123", "app-user");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getJobId()).isEqualTo(jobId);
assertThat(history.getReason()).isEqualTo("Aufgabe abgeschlossen");
assertThat(history.getDescription()).contains("PHOTO");
assertThat(history.getChangedBy()).isEqualTo("app-user");
assertThat(history.getChangeType()).isEqualTo(JobHistoryType.TASK_COMPLETED);
}
@Test
@DisplayName("should log task completion with display name and extra data")
void shouldLogTaskCompletionWithDisplayNameAndExtraData() {
// Given
ObjectId jobId = new ObjectId();
// When
service.logTaskCompletion(jobId, "PHOTO", "task-123", "app-user",
"Ablieferungsfoto", "3 Fotos hochgeladen");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("Ablieferungsfoto");
assertThat(history.getDescription()).contains("3 Fotos hochgeladen");
assertThat(history.getDetails()).contains("Task-ID: task-123");
assertThat(history.getDetails()).contains("Task-Typ: PHOTO");
assertThat(history.getDetails()).contains("Name: Ablieferungsfoto");
assertThat(history.getDetails()).contains("Zusatzdaten: 3 Fotos hochgeladen");
}
@Test
@DisplayName("should handle null display name")
void shouldHandleNullDisplayName() {
// Given
ObjectId jobId = new ObjectId();
// When
service.logTaskCompletion(jobId, "BARCODE", "task-123", "app-user", null, null);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).contains("BARCODE");
}
}
@Nested
@DisplayName("logJobAssignment Tests")
class LogJobAssignmentTests {
@Test
@DisplayName("should log new assignment")
void shouldLogNewAssignment() {
// Given
Job job = createTestJob();
// When
service.logJobAssignment(job, null, "Max Mustermann", "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getReason()).isEqualTo("Zuweisung geändert");
assertThat(history.getDescription()).isEqualTo("Job zugewiesen an: Max Mustermann");
assertThat(history.getChangeType()).isEqualTo(JobHistoryType.ASSIGNMENT);
assertThat(history.getOldValue()).isNull();
assertThat(history.getNewValue()).isEqualTo("Max Mustermann");
}
@Test
@DisplayName("should log assignment removal")
void shouldLogAssignmentRemoval() {
// Given
Job job = createTestJob();
// When
service.logJobAssignment(job, "Max Mustermann", null, "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription()).isEqualTo("Job-Zuweisung entfernt von: Max Mustermann");
}
@Test
@DisplayName("should log assignment change")
void shouldLogAssignmentChange() {
// Given
Job job = createTestJob();
// When
service.logJobAssignment(job, "Max Mustermann", "Erika Musterfrau", "user-123");
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getDescription())
.isEqualTo("Job-Zuweisung geändert von Max Mustermann zu Erika Musterfrau");
}
}
@Nested
@DisplayName("logCustomEvent Tests")
class LogCustomEventTests {
@Test
@DisplayName("should log custom event correctly")
void shouldLogCustomEventCorrectly() {
// Given
ObjectId jobId = new ObjectId();
// When
service.logCustomEvent(jobId, "Export", "Job als PDF exportiert",
"user-123", JobHistoryType.EXPORT);
// Then
verify(jobHistoryRepository).save(historyCaptor.capture());
JobHistory history = historyCaptor.getValue();
assertThat(history.getJobId()).isEqualTo(jobId);
assertThat(history.getReason()).isEqualTo("Export");
assertThat(history.getDescription()).isEqualTo("Job als PDF exportiert");
assertThat(history.getChangedBy()).isEqualTo("user-123");
assertThat(history.getChangeType()).isEqualTo(JobHistoryType.EXPORT);
}
}
@Nested
@DisplayName("getJobHistory Tests")
class GetJobHistoryTests {
@Test
@DisplayName("should return job history from repository")
void shouldReturnJobHistoryFromRepository() {
// Given
ObjectId jobId = new ObjectId();
JobHistory history1 = new JobHistory(jobId, "Event 1", "Description 1", "user-1");
JobHistory history2 = new JobHistory(jobId, "Event 2", "Description 2", "user-2");
List<JobHistory> expectedHistory = Arrays.asList(history1, history2);
when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(expectedHistory);
// When
List<JobHistory> result = service.getJobHistory(jobId);
// Then
assertThat(result).hasSize(2);
assertThat(result).containsExactlyElementsOf(expectedHistory);
}
@Test
@DisplayName("should return empty list when no history exists")
void shouldReturnEmptyListWhenNoHistoryExists() {
// Given
ObjectId jobId = new ObjectId();
when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(Collections.emptyList());
// When
List<JobHistory> result = service.getJobHistory(jobId);
// Then
assertThat(result).isEmpty();
}
}
@Nested
@DisplayName("getJobHistoryCount Tests")
class GetJobHistoryCountTests {
@Test
@DisplayName("should return correct count from repository")
void shouldReturnCorrectCountFromRepository() {
// Given
ObjectId jobId = new ObjectId();
when(jobHistoryRepository.countByJobId(jobId)).thenReturn(5L);
// When
long count = service.getJobHistoryCount(jobId);
// Then
assertThat(count).isEqualTo(5L);
}
@Test
@DisplayName("should return zero when no history exists")
void shouldReturnZeroWhenNoHistoryExists() {
// Given
ObjectId jobId = new ObjectId();
when(jobHistoryRepository.countByJobId(jobId)).thenReturn(0L);
// When
long count = service.getJobHistoryCount(jobId);
// Then
assertThat(count).isZero();
}
}
}