From 5768a37c5ea04c623b0424bd513c0277300ebcee Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 27 Jan 2026 09:58:28 +0100 Subject: [PATCH] Erweiterungen --- pom.xml | 2 +- .../ai/service/AiStatisticsService.java | 580 ++++++++++++++--- .../votianlt/ai/service/LlmRestClient.java | 13 +- .../votianlt/pages/view/StatisticsView.java | 120 +++- .../votianlt/repository/JobRepository.java | 6 + .../service/JobStatisticsService.java | 247 ++++++- .../model/JobJsonSerializationTest.java | 601 ------------------ .../service/ClientConnectionServiceTest.java | 489 -------------- .../votianlt/service/EmailServiceTest.java | 402 ------------ .../service/JobHistoryServiceTest.java | 536 ---------------- 10 files changed, 861 insertions(+), 2135 deletions(-) delete mode 100644 src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java delete mode 100644 src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java delete mode 100644 src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java delete mode 100644 src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java diff --git a/pom.xml b/pom.xml index 01bda3d..4cb2cd8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ de.assecutor.votianlt votianlt - 0.8.4 + 0.8.5 jar diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java index bb128dc..0aacecd 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java @@ -2,6 +2,7 @@ package de.assecutor.votianlt.ai.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.service.JobStatisticsService; import lombok.extern.slf4j.Slf4j; @@ -47,40 +48,32 @@ public class AiStatisticsService { public StatisticsResponse analyzeStatisticsQuery(String userQuery) { log.info("Processing statistics query: {}", userQuery); - // Gather current statistics - String statisticsContext = buildStatisticsContext(); - - // Determine query type and prepare chart data + // Determine query type and prepare chart data (includes customer filter detection) 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 String prompt = buildPrompt(userQuery, statisticsContext, analysis); - // System prompt for the statistics assistant - String systemPrompt = """ - Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen. - Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken. - - 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). - """; + // System prompt - different for list vs statistics queries + String systemPrompt = analysis.queryType.equals("list") + ? buildListSystemPrompt() + : buildStatisticsSystemPrompt(); // Call LLM via direct REST client (like aimailassistant) String llmResponse = llmClient.chat(systemPrompt, prompt); - if (llmResponse != null) { + if (llmResponse != null && !llmResponse.isBlank()) { log.info("LLM response received, length: {} chars", llmResponse.length()); return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData); } else { - log.warn("LLM returned null response, using fallback"); + log.warn("LLM returned null or blank response, using fallback"); return new StatisticsResponse( buildFallbackResponse(analysis), analysis.chartType, @@ -92,45 +85,284 @@ public class AiStatisticsService { private record QueryAnalysis( String queryType, 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) { String lowerQuery = query.toLowerCase(); - // Status-bezogene Anfragen - if (lowerQuery.contains("status") || lowerQuery.contains("offen") || - lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") || - lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) { - return new QueryAnalysis("status", "doughnut", buildStatusChartData()); + // First, check if this is a LIST query (no chart needed) + boolean isListQuery = isListQuery(lowerQuery); + log.debug("Is list query: {}", isListQuery); + + // 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 - if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || - lowerQuery.contains("kunde") || lowerQuery.contains("customer") || + else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || lowerQuery.contains("einnahmen")) { - return new QueryAnalysis("revenue", "bar", buildRevenueChartData()); + queryType = "revenue"; + defaultChartType = "bar"; + chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData(); } - // 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("verlauf")) { - return new QueryAnalysis("trend", "line", buildTrendChartData()); + queryType = "trend"; + defaultChartType = "line"; + chartData = buildTrendChartData(customerFilter); } - // Task-bezogene Anfragen - if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || + else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || lowerQuery.contains("erledigt")) { - return new QueryAnalysis("tasks", "doughnut", buildTaskChartData()); + queryType = "tasks"; + defaultChartType = "doughnut"; + chartData = buildTaskChartData(); + } + // Allgemeine Übersicht + else { + queryType = "overview"; + defaultChartType = "bar"; + chartData = buildOverviewChartData(customerFilter); } - // Allgemeine Übersicht - return new QueryAnalysis("overview", "bar", buildOverviewChartData()); + // Use user's chart type preference if specified, otherwise use default + String chartType = userChartType != null ? userChartType : defaultChartType; + + return new QueryAnalysis(queryType, chartType, chartData, customerFilter, null); } - private String buildStatusChartData() { - Map statusCounts = statisticsService.getJobCountsByStatus(); + /** + * 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 statusCounts = customerFilter != null + ? statisticsService.getJobCountsByStatusForCustomer(customerFilter) + : statisticsService.getJobCountsByStatus(); List labels = new ArrayList<>(); List 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() { @@ -177,9 +410,30 @@ public class AiStatisticsService { 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 labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)"); + List data = List.of( + (double) totalJobs, + (double) completed, + (double) inProgress, + totalRevenue.doubleValue() / 100 // Scale down for better visualization + ); + List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1"); + + return buildChartJsonDouble(labels, data, colors, customer); + } + + private String buildTrendChartData(String customerFilter) { int currentYear = Year.now().getValue(); - Map monthlyData = statisticsService.getMonthlyJobCounts(currentYear); + Map monthlyData = customerFilter != null + ? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter) + : statisticsService.getMonthlyJobCounts(currentYear); List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"); @@ -189,11 +443,15 @@ public class AiStatisticsService { 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(""" { "labels": %s, "datasets": [{ - "label": "Aufträge %d", + "label": "%s", "data": %s, "borderColor": "#6366f1", "backgroundColor": "rgba(99, 102, 241, 0.15)", @@ -206,7 +464,7 @@ public class AiStatisticsService { "fill": true }] } - """, toJsonArray(labels), currentYear, data); + """, toJsonArray(labels), datasetLabel, data); } private String buildTaskChartData() { @@ -222,10 +480,14 @@ public class AiStatisticsService { return buildChartJson(labels, data, colors, "Aufgaben"); } - private String buildOverviewChartData() { - Map statusCounts = statisticsService.getJobCountsByStatus(); + private String buildOverviewChartData(String customerFilter) { + Map 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 inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L); @@ -234,7 +496,8 @@ public class AiStatisticsService { List data = List.of(total, completed, inProgress, open); List 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 labels, List data, List colors, String label) { @@ -273,37 +536,103 @@ public class AiStatisticsService { } } - private String buildStatisticsContext() { + private String buildStatisticsContext(QueryAnalysis analysis) { + StringBuilder context = new StringBuilder(); + String customerFilter = analysis.customerFilter; + JobStatus statusFilter = analysis.statusFilter; + + // 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(); + context.append("**Aktuelle Auftragsstatistiken:**\n"); + statusCounts.forEach((status, count) -> + context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); + + context.append(String.format("\n**Gesamtübersicht:**\n")); + context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); + context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate())); + context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue())); + + // Task statistics + var taskStats = statisticsService.getTaskCompletionStats(); + context.append(String.format("\n**Aufgaben:**\n")); + context.append(String.format("- Gesamt: %d\n", taskStats.get("total"))); + context.append(String.format("- Erledigt: %d\n", taskStats.get("completed"))); + context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending"))); + + // Top customers + var topCustomers = statisticsService.getTopCustomersByRevenue(5); + if (!topCustomers.isEmpty()) { + context.append("\n**Top 5 Kunden nach Umsatz:**\n"); + for (var entry : topCustomers) { + context.append(String.format("- %s: %.2f EUR\n", + entry.getKey() != null ? entry.getKey() : "Unbekannt", + entry.getValue())); + } + } + } + + return context.toString(); + } + + private String buildListContext(String customerFilter, JobStatus statusFilter) { StringBuilder context = new StringBuilder(); - // Job counts by status - var statusCounts = statisticsService.getJobCountsByStatus(); - context.append("**Aktuelle Auftragsstatistiken:**\n"); - statusCounts.forEach((status, count) -> - context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); + List 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"); + } - // Totals - context.append(String.format("\n**Gesamtübersicht:**\n")); - context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); - context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate())); - context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue())); + context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size())); - // Task statistics - var taskStats = statisticsService.getTaskCompletionStats(); - context.append(String.format("\n**Aufgaben:**\n")); - context.append(String.format("- Gesamt: %d\n", taskStats.get("total"))); - context.append(String.format("- Erledigt: %d\n", taskStats.get("completed"))); - context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending"))); - - // Top customers - var topCustomers = statisticsService.getTopCustomersByRevenue(5); - if (!topCustomers.isEmpty()) { - context.append("\n**Top 5 Kunden nach Umsatz:**\n"); - for (var entry : topCustomers) { - context.append(String.format("- %s: %.2f EUR\n", - entry.getKey() != null ? entry.getKey() : "Unbekannt", - entry.getValue())); + // 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(); @@ -319,37 +648,79 @@ public class AiStatisticsService { } private String buildFallbackResponse(QueryAnalysis analysis) { + String customer = analysis.customerFilter; + JobStatus statusFilter = analysis.statusFilter; + return switch (analysis.queryType) { + case "list" -> { + List 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" -> { - var counts = statisticsService.getJobCountsByStatus(); - StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n"); + var counts = customer != null + ? 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) -> { if (count > 0) { 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(); } case "revenue" -> { - var topCustomers = statisticsService.getTopCustomersByRevenue(5); - StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); - int rank = 1; - for (var entry : topCustomers) { - sb.append(String.format("%d. **%s:** %.2f EUR\n", - rank++, - entry.getKey() != null ? entry.getKey() : "Unbekannt", - entry.getValue())); + 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); + StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); + int rank = 1; + for (var entry : topCustomers) { + sb.append(String.format("%d. **%s:** %.2f EUR\n", + rank++, + entry.getKey() != null ? entry.getKey() : "Unbekannt", + entry.getValue())); + } + sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); + yield sb.toString(); } - sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); - yield sb.toString(); } case "trend" -> { 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(); - yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " + - "Die Verteilung ist im Diagramm ersichtlich.", year, total); + String title = customer != null + ? 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" -> { var taskStats = statisticsService.getTaskCompletionStats(); @@ -363,13 +734,24 @@ public class AiStatisticsService { total, completed, rate, taskStats.getOrDefault("pending", 0L)); } default -> { - yield String.format("**Übersicht:**\n\n" + - "- **Aufträge gesamt:** %d\n" + - "- **Abschlussrate:** %.1f%%\n" + - "- **Gesamtumsatz:** %.2f EUR", - statisticsService.getTotalJobCount(), - statisticsService.getCompletionRate(), - statisticsService.getTotalRevenue()); + 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" + + "- **Aufträge gesamt:** %d\n" + + "- **Abschlussrate:** %.1f%%\n" + + "- **Gesamtumsatz:** %.2f EUR", + statisticsService.getTotalJobCount(), + statisticsService.getCompletionRate(), + statisticsService.getTotalRevenue()); + } } }; } diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java index d827710..25fed44 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java @@ -106,16 +106,23 @@ public class LlmRestClient { } private String extractContent(String response) { - if (response == null) { + if (response == null || response.isBlank()) { + log.warn("LLM returned null or blank response"); return null; } try { JsonNode root = objectMapper.readTree(response); JsonNode choices = root.path("choices"); 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; + } + return content; } - log.warn("Unexpected response structure: {}", response); + log.warn("Unexpected response structure (no choices): {}", response); return null; } catch (Exception e) { log.error("Error parsing LLM response: {}", e.getMessage()); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java index 50ef2ca..0e1f5ff 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java @@ -262,7 +262,11 @@ public class StatisticsView extends VerticalLayout { // Text Response Div textDiv = new Div(); 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); // Chart wenn vorhanden @@ -505,7 +509,7 @@ public class StatisticsView extends VerticalLayout { } } """; - case "doughnut", "pie" -> """ + case "doughnut" -> """ { responsive: true, 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 -> """ { responsive: true, diff --git a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java index 9535c45..76640a8 100644 --- a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java @@ -65,6 +65,12 @@ public interface JobRepository extends MongoRepository { */ List findByCustomerSelection(String customerSelection); + /** + * Findet Aufträge anhand einer Kundenauswahl (case-insensitive) + */ + @Query("{ 'customerSelection': { $regex: ?0, $options: 'i' } }") + List findByCustomerSelectionIgnoreCase(String customerSelection); + /** * Prüft, ob eine Auftragsnummer bereits existiert */ diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java index 2d78908..ad6fc82 100644 --- a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java +++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java @@ -16,6 +16,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * 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 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 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 getLatestJobs(int limit) { return jobRepository.findLatestJobs().stream().limit(limit).toList(); } + + // ==================== Filtered Statistics Methods ==================== + + /** + * Get all available customer names for autocomplete/filtering. + */ + public List 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 getJobCountsByStatusForCustomer(String customer) { + List customerJobs = getJobsByCustomer(customer); + Map 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 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 getMonthlyJobCountsForCustomer(int year, String customer) { + Map monthlyCounts = new LinkedHashMap<>(); + LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0); + LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59); + + List 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 getJobsByCustomerAndStatus(String customer, JobStatus status) { + List 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 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()); + } } diff --git a/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java b/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java deleted file mode 100644 index 2a575ab..0000000 --- a/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java +++ /dev/null @@ -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 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 tasks = createAllTaskTypes(job.getId()); - - // Create wrapper object similar to the spec - record JobWithTasks(Job job, List 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 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); - } -} diff --git a/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java b/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java deleted file mode 100644 index e2eccf5..0000000 --- a/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java +++ /dev/null @@ -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 connectedClients = - (java.util.Map) 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 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 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 connectedClients = - (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients"); - connectedClients.put("client-2", disconnectedState); - - // When - Set 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 connectedClients = - (java.util.Map) 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 connectedClients = - (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients"); - connectedClients.put("client-2", disconnectedState); - - // When/Then - assertThat(service.getConnectedClientCount()).isEqualTo(1); - assertThat(service.getTotalClientCount()).isEqualTo(2); - } - } -} diff --git a/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java b/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java deleted file mode 100644 index 6e4abd8..0000000 --- a/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java +++ /dev/null @@ -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 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"); - } - } -} diff --git a/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java b/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java deleted file mode 100644 index a7ba540..0000000 --- a/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java +++ /dev/null @@ -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 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 expectedHistory = Arrays.asList(history1, history2); - - when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(expectedHistory); - - // When - List 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 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(); - } - } -}