start
This commit is contained in:
@@ -6,10 +6,8 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -50,7 +48,9 @@ class ImageProcessingService {
|
||||
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
var dir = Paths.get("images", String.valueOf(saleId), String.valueOf(lotId));
|
||||
// Use Windows path: C:\mnt\okcomputer\output\images
|
||||
var baseDir = Paths.get("C:", "mnt", "okcomputer", "output", "images");
|
||||
var dir = baseDir.resolve(String.valueOf(saleId)).resolve(String.valueOf(lotId));
|
||||
Files.createDirectories(dir);
|
||||
|
||||
var fileName = Paths.get(imageUrl).getFileName().toString();
|
||||
|
||||
@@ -24,8 +24,11 @@ public class Main {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Console.println("=== Troostwijk Auction Monitor ===\n");
|
||||
|
||||
// Configuration
|
||||
String databaseFile = System.getenv().getOrDefault("DATABASE_FILE", "troostwijk.db");
|
||||
// Parse command line arguments
|
||||
String mode = args.length > 0 ? args[0] : "workflow";
|
||||
|
||||
// Configuration - Windows paths
|
||||
String databaseFile = System.getenv().getOrDefault("DATABASE_FILE", "C:\\mnt\\okcomputer\\output\\cache.db");
|
||||
String notificationConfig = System.getenv().getOrDefault("NOTIFICATION_CONFIG", "desktop");
|
||||
|
||||
// YOLO model paths (optional - monitor works without object detection)
|
||||
@@ -41,19 +44,109 @@ public class Main {
|
||||
Console.println("⚠️ OpenCV not available - image detection disabled");
|
||||
}
|
||||
|
||||
Console.println("Initializing monitor...");
|
||||
var monitor = new TroostwijkMonitor(databaseFile, notificationConfig,
|
||||
switch (mode.toLowerCase()) {
|
||||
case "workflow":
|
||||
runWorkflowMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
||||
break;
|
||||
|
||||
case "once":
|
||||
runOnceMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
||||
break;
|
||||
|
||||
case "legacy":
|
||||
runLegacyMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
showStatus(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
||||
break;
|
||||
|
||||
default:
|
||||
showUsage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WORKFLOW MODE: Run orchestrated scheduled workflows (default)
|
||||
* This is the recommended mode for production use.
|
||||
*/
|
||||
private static void runWorkflowMode(String dbPath, String notifConfig,
|
||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
||||
throws Exception {
|
||||
|
||||
Console.println("🚀 Starting in WORKFLOW MODE (Orchestrated Scheduling)\n");
|
||||
|
||||
WorkflowOrchestrator orchestrator = new WorkflowOrchestrator(
|
||||
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
|
||||
);
|
||||
|
||||
// Show initial status
|
||||
orchestrator.printStatus();
|
||||
|
||||
// Start all scheduled workflows
|
||||
orchestrator.startScheduledWorkflows();
|
||||
|
||||
Console.println("✓ All workflows are running");
|
||||
Console.println(" - Scraper import: every 30 min");
|
||||
Console.println(" - Image processing: every 1 hour");
|
||||
Console.println(" - Bid monitoring: every 15 min");
|
||||
Console.println(" - Closing alerts: every 5 min");
|
||||
Console.println("\nPress Ctrl+C to stop.\n");
|
||||
|
||||
// Add shutdown hook
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
Console.println("\n🛑 Shutdown signal received...");
|
||||
orchestrator.shutdown();
|
||||
}));
|
||||
|
||||
// Keep application alive
|
||||
try {
|
||||
Thread.sleep(Long.MAX_VALUE);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
orchestrator.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ONCE MODE: Run complete workflow once and exit
|
||||
* Useful for cron jobs or scheduled tasks.
|
||||
*/
|
||||
private static void runOnceMode(String dbPath, String notifConfig,
|
||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
||||
throws Exception {
|
||||
|
||||
Console.println("🔄 Starting in ONCE MODE (Single Execution)\n");
|
||||
|
||||
WorkflowOrchestrator orchestrator = new WorkflowOrchestrator(
|
||||
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
|
||||
);
|
||||
|
||||
orchestrator.runCompleteWorkflowOnce();
|
||||
|
||||
Console.println("✓ Workflow execution completed. Exiting.\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* LEGACY MODE: Original monitoring approach
|
||||
* Kept for backward compatibility.
|
||||
*/
|
||||
private static void runLegacyMode(String dbPath, String notifConfig,
|
||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
||||
throws Exception {
|
||||
|
||||
Console.println("⚙️ Starting in LEGACY MODE\n");
|
||||
|
||||
var monitor = new TroostwijkMonitor(dbPath, notifConfig,
|
||||
yoloCfg, yoloWeights, yoloClasses);
|
||||
|
||||
// Show current database state
|
||||
Console.println("\n📊 Current Database State:");
|
||||
monitor.printDatabaseStats();
|
||||
|
||||
// Check for pending image processing
|
||||
Console.println("\n[1/2] Processing images...");
|
||||
monitor.processPendingImages();
|
||||
|
||||
// Start monitoring service
|
||||
Console.println("\n[2/2] Starting bid monitoring...");
|
||||
monitor.scheduleMonitoring();
|
||||
|
||||
@@ -61,7 +154,6 @@ public class Main {
|
||||
Console.println("NOTE: This process expects auction/lot data from the external scraper.");
|
||||
Console.println(" Make sure ARCHITECTURE-TROOSTWIJK-SCRAPER is running and populating the database.\n");
|
||||
|
||||
// Keep application alive
|
||||
try {
|
||||
Thread.sleep(Long.MAX_VALUE);
|
||||
} catch (InterruptedException e) {
|
||||
@@ -70,6 +162,44 @@ public class Main {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STATUS MODE: Show current status and exit
|
||||
*/
|
||||
private static void showStatus(String dbPath, String notifConfig,
|
||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
||||
throws Exception {
|
||||
|
||||
Console.println("📊 Checking Status...\n");
|
||||
|
||||
WorkflowOrchestrator orchestrator = new WorkflowOrchestrator(
|
||||
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
|
||||
);
|
||||
|
||||
orchestrator.printStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show usage information
|
||||
*/
|
||||
private static void showUsage() {
|
||||
Console.println("Usage: java -jar troostwijk-monitor.jar [mode]\n");
|
||||
Console.println("Modes:");
|
||||
Console.println(" workflow - Run orchestrated scheduled workflows (default)");
|
||||
Console.println(" once - Run complete workflow once and exit (for cron)");
|
||||
Console.println(" legacy - Run original monitoring approach");
|
||||
Console.println(" status - Show current status and exit");
|
||||
Console.println("\nEnvironment Variables:");
|
||||
Console.println(" DATABASE_FILE - Path to SQLite database");
|
||||
Console.println(" (default: C:\\mnt\\okcomputer\\output\\cache.db)");
|
||||
Console.println(" NOTIFICATION_CONFIG - 'desktop' or 'smtp:user:pass:email'");
|
||||
Console.println(" (default: desktop)");
|
||||
Console.println("\nExamples:");
|
||||
Console.println(" java -jar troostwijk-monitor.jar workflow");
|
||||
Console.println(" java -jar troostwijk-monitor.jar once");
|
||||
Console.println(" java -jar troostwijk-monitor.jar status");
|
||||
Console.println();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative entry point for container environments.
|
||||
* Simply keeps the container alive for manual commands.
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.opencv.dnn.Net;
|
||||
import org.opencv.imgcodecs.Imgcodecs;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
442
src/main/java/com/auction/WorkflowOrchestrator.java
Normal file
442
src/main/java/com/auction/WorkflowOrchestrator.java
Normal file
@@ -0,0 +1,442 @@
|
||||
package com.auction;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Orchestrates the complete workflow of auction monitoring, image processing,
|
||||
* object detection, and notifications.
|
||||
*
|
||||
* This class coordinates all services and provides scheduled execution,
|
||||
* event-driven triggers, and manual workflow execution.
|
||||
*/
|
||||
public class WorkflowOrchestrator {
|
||||
|
||||
private final TroostwijkMonitor monitor;
|
||||
private final DatabaseService db;
|
||||
private final ImageProcessingService imageProcessor;
|
||||
private final NotificationService notifier;
|
||||
private final ObjectDetectionService detector;
|
||||
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private boolean isRunning = false;
|
||||
|
||||
/**
|
||||
* Creates a workflow orchestrator with all necessary services.
|
||||
*/
|
||||
public WorkflowOrchestrator(String databasePath, String notificationConfig,
|
||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
||||
throws SQLException, IOException {
|
||||
|
||||
Console.println("🔧 Initializing Workflow Orchestrator...");
|
||||
|
||||
// Initialize core services
|
||||
this.db = new DatabaseService(databasePath);
|
||||
this.db.ensureSchema();
|
||||
|
||||
this.notifier = new NotificationService(notificationConfig, "");
|
||||
this.detector = new ObjectDetectionService(yoloCfg, yoloWeights, yoloClasses);
|
||||
this.imageProcessor = new ImageProcessingService(db, detector);
|
||||
|
||||
this.monitor = new TroostwijkMonitor(databasePath, notificationConfig,
|
||||
yoloCfg, yoloWeights, yoloClasses);
|
||||
|
||||
this.scheduler = Executors.newScheduledThreadPool(3);
|
||||
|
||||
Console.println("✓ Workflow Orchestrator initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts all scheduled workflows.
|
||||
* This is the main entry point for automated operation.
|
||||
*/
|
||||
public void startScheduledWorkflows() {
|
||||
if (isRunning) {
|
||||
Console.println("⚠️ Workflows already running");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.println("\n🚀 Starting Scheduled Workflows...\n");
|
||||
|
||||
// Workflow 1: Import scraper data (every 30 minutes)
|
||||
scheduleScraperDataImport();
|
||||
|
||||
// Workflow 2: Process pending images (every 1 hour)
|
||||
scheduleImageProcessing();
|
||||
|
||||
// Workflow 3: Monitor bids (every 15 minutes)
|
||||
scheduleBidMonitoring();
|
||||
|
||||
// Workflow 4: Check closing times (every 5 minutes)
|
||||
scheduleClosingAlerts();
|
||||
|
||||
isRunning = true;
|
||||
Console.println("✓ All scheduled workflows started\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow 1: Import Scraper Data
|
||||
* Frequency: Every 30 minutes
|
||||
* Purpose: Import new auctions and lots from external scraper
|
||||
*/
|
||||
private void scheduleScraperDataImport() {
|
||||
scheduler.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
Console.println("📥 [WORKFLOW 1] Importing scraper data...");
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Import auctions
|
||||
var auctions = db.importAuctionsFromScraper();
|
||||
Console.println(" → Imported " + auctions.size() + " auctions");
|
||||
|
||||
// Import lots
|
||||
var lots = db.importLotsFromScraper();
|
||||
Console.println(" → Imported " + lots.size() + " lots");
|
||||
|
||||
// Import image URLs
|
||||
var images = db.getUnprocessedImagesFromScraper();
|
||||
Console.println(" → Found " + images.size() + " unprocessed images");
|
||||
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
Console.println(" ✓ Scraper import completed in " + duration + "ms\n");
|
||||
|
||||
// Trigger notification if significant data imported
|
||||
if (auctions.size() > 0 || lots.size() > 10) {
|
||||
notifier.sendNotification(
|
||||
String.format("Imported %d auctions, %d lots", auctions.size(), lots.size()),
|
||||
"Data Import Complete",
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Scraper import failed: " + e.getMessage());
|
||||
}
|
||||
}, 0, 30, TimeUnit.MINUTES);
|
||||
|
||||
Console.println(" ✓ Scheduled: Scraper Data Import (every 30 min)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow 2: Process Pending Images
|
||||
* Frequency: Every 1 hour
|
||||
* Purpose: Download images and run object detection
|
||||
*/
|
||||
private void scheduleImageProcessing() {
|
||||
scheduler.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
Console.println("🖼️ [WORKFLOW 2] Processing pending images...");
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Get unprocessed images
|
||||
var unprocessedImages = db.getUnprocessedImagesFromScraper();
|
||||
|
||||
if (unprocessedImages.isEmpty()) {
|
||||
Console.println(" → No pending images to process\n");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.println(" → Processing " + unprocessedImages.size() + " images");
|
||||
|
||||
int processed = 0;
|
||||
int detected = 0;
|
||||
|
||||
for (var imageRecord : unprocessedImages) {
|
||||
try {
|
||||
// Download image
|
||||
String filePath = imageProcessor.downloadImage(
|
||||
imageRecord.url(),
|
||||
imageRecord.saleId(),
|
||||
imageRecord.lotId()
|
||||
);
|
||||
|
||||
if (filePath != null) {
|
||||
// Run object detection
|
||||
var labels = detector.detectObjects(filePath);
|
||||
|
||||
// Save to database
|
||||
db.insertImage(imageRecord.lotId(), imageRecord.url(),
|
||||
filePath, labels);
|
||||
|
||||
processed++;
|
||||
if (!labels.isEmpty()) {
|
||||
detected++;
|
||||
|
||||
// Send notification for interesting detections
|
||||
if (labels.size() >= 3) {
|
||||
notifier.sendNotification(
|
||||
String.format("Lot %d: Detected %s",
|
||||
imageRecord.lotId(),
|
||||
String.join(", ", labels)),
|
||||
"Objects Detected",
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
Thread.sleep(500);
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ⚠️ Failed to process image: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
Console.println(String.format(" ✓ Processed %d images, detected objects in %d (%.1fs)\n",
|
||||
processed, detected, duration / 1000.0));
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Image processing failed: " + e.getMessage());
|
||||
}
|
||||
}, 5, 60, TimeUnit.MINUTES);
|
||||
|
||||
Console.println(" ✓ Scheduled: Image Processing (every 1 hour)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow 3: Monitor Bids
|
||||
* Frequency: Every 15 minutes
|
||||
* Purpose: Check for bid changes and send notifications
|
||||
*/
|
||||
private void scheduleBidMonitoring() {
|
||||
scheduler.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
Console.println("💰 [WORKFLOW 3] Monitoring bids...");
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
var activeLots = db.getActiveLots();
|
||||
Console.println(" → Checking " + activeLots.size() + " active lots");
|
||||
|
||||
int bidChanges = 0;
|
||||
|
||||
for (var lot : activeLots) {
|
||||
// Note: In production, this would call Troostwijk API
|
||||
// For now, we just track what's in the database
|
||||
// The external scraper updates bids, we just notify
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
Console.println(String.format(" ✓ Bid monitoring completed in %dms\n", duration));
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Bid monitoring failed: " + e.getMessage());
|
||||
}
|
||||
}, 2, 15, TimeUnit.MINUTES);
|
||||
|
||||
Console.println(" ✓ Scheduled: Bid Monitoring (every 15 min)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow 4: Check Closing Times
|
||||
* Frequency: Every 5 minutes
|
||||
* Purpose: Send alerts for lots closing soon
|
||||
*/
|
||||
private void scheduleClosingAlerts() {
|
||||
scheduler.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
Console.println("⏰ [WORKFLOW 4] Checking closing times...");
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
var activeLots = db.getActiveLots();
|
||||
int alertsSent = 0;
|
||||
|
||||
for (var lot : activeLots) {
|
||||
if (lot.closingTime() == null) continue;
|
||||
|
||||
long minutesLeft = lot.minutesUntilClose();
|
||||
|
||||
// Alert for lots closing in 5 minutes
|
||||
if (minutesLeft <= 5 && minutesLeft > 0 && !lot.closingNotified()) {
|
||||
String message = String.format("Kavel %d sluit binnen %d min.",
|
||||
lot.lotId(), minutesLeft);
|
||||
|
||||
notifier.sendNotification(message, "Lot Closing Soon", 1);
|
||||
|
||||
// Mark as notified
|
||||
var updated = new Lot(
|
||||
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
|
||||
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
||||
lot.currentBid(), lot.currency(), lot.url(),
|
||||
lot.closingTime(), true
|
||||
);
|
||||
db.updateLotNotificationFlags(updated);
|
||||
|
||||
alertsSent++;
|
||||
}
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
Console.println(String.format(" → Sent %d closing alerts in %dms\n",
|
||||
alertsSent, duration));
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Closing alerts failed: " + e.getMessage());
|
||||
}
|
||||
}, 1, 5, TimeUnit.MINUTES);
|
||||
|
||||
Console.println(" ✓ Scheduled: Closing Alerts (every 5 min)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual trigger: Run complete workflow once
|
||||
* Useful for testing or on-demand execution
|
||||
*/
|
||||
public void runCompleteWorkflowOnce() {
|
||||
Console.println("\n🔄 Running Complete Workflow (Manual Trigger)...\n");
|
||||
|
||||
try {
|
||||
// Step 1: Import data
|
||||
Console.println("[1/4] Importing scraper data...");
|
||||
var auctions = db.importAuctionsFromScraper();
|
||||
var lots = db.importLotsFromScraper();
|
||||
Console.println(" ✓ Imported " + auctions.size() + " auctions, " + lots.size() + " lots");
|
||||
|
||||
// Step 2: Process images
|
||||
Console.println("[2/4] Processing pending images...");
|
||||
monitor.processPendingImages();
|
||||
Console.println(" ✓ Image processing completed");
|
||||
|
||||
// Step 3: Check bids
|
||||
Console.println("[3/4] Monitoring bids...");
|
||||
var activeLots = db.getActiveLots();
|
||||
Console.println(" ✓ Monitored " + activeLots.size() + " lots");
|
||||
|
||||
// Step 4: Check closing times
|
||||
Console.println("[4/4] Checking closing times...");
|
||||
int closingSoon = 0;
|
||||
for (var lot : activeLots) {
|
||||
if (lot.closingTime() != null && lot.minutesUntilClose() < 30) {
|
||||
closingSoon++;
|
||||
}
|
||||
}
|
||||
Console.println(" ✓ Found " + closingSoon + " lots closing soon");
|
||||
|
||||
Console.println("\n✓ Complete workflow finished successfully\n");
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println("\n❌ Workflow failed: " + e.getMessage() + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven trigger: New auction discovered
|
||||
*/
|
||||
public void onNewAuctionDiscovered(AuctionInfo auction) {
|
||||
Console.println("📣 EVENT: New auction discovered - " + auction.title());
|
||||
|
||||
try {
|
||||
db.upsertAuction(auction);
|
||||
|
||||
notifier.sendNotification(
|
||||
String.format("New auction: %s\nLocation: %s\nLots: %d",
|
||||
auction.title(), auction.location(), auction.lotCount()),
|
||||
"New Auction Discovered",
|
||||
0
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Failed to handle new auction: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven trigger: Bid change detected
|
||||
*/
|
||||
public void onBidChange(Lot lot, double previousBid, double newBid) {
|
||||
Console.println(String.format("📣 EVENT: Bid change on lot %d (€%.2f → €%.2f)",
|
||||
lot.lotId(), previousBid, newBid));
|
||||
|
||||
try {
|
||||
db.updateLotCurrentBid(lot);
|
||||
|
||||
notifier.sendNotification(
|
||||
String.format("Nieuw bod op kavel %d: €%.2f (was €%.2f)",
|
||||
lot.lotId(), newBid, previousBid),
|
||||
"Kavel Bieding Update",
|
||||
0
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Failed to handle bid change: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven trigger: Objects detected in image
|
||||
*/
|
||||
public void onObjectsDetected(int lotId, List<String> labels) {
|
||||
Console.println(String.format("📣 EVENT: Objects detected in lot %d - %s",
|
||||
lotId, String.join(", ", labels)));
|
||||
|
||||
try {
|
||||
if (labels.size() >= 2) {
|
||||
notifier.sendNotification(
|
||||
String.format("Lot %d contains: %s", lotId, String.join(", ", labels)),
|
||||
"Objects Detected",
|
||||
0
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Console.println(" ❌ Failed to send detection notification: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints current workflow status
|
||||
*/
|
||||
public void printStatus() {
|
||||
Console.println("\n📊 Workflow Status:");
|
||||
Console.println(" Running: " + (isRunning ? "Yes" : "No"));
|
||||
|
||||
try {
|
||||
var auctions = db.getAllAuctions();
|
||||
var lots = db.getAllLots();
|
||||
int images = db.getImageCount();
|
||||
|
||||
Console.println(" Auctions: " + auctions.size());
|
||||
Console.println(" Lots: " + lots.size());
|
||||
Console.println(" Images: " + images);
|
||||
|
||||
// Count closing soon
|
||||
int closingSoon = 0;
|
||||
for (var lot : lots) {
|
||||
if (lot.closingTime() != null && lot.minutesUntilClose() < 30) {
|
||||
closingSoon++;
|
||||
}
|
||||
}
|
||||
Console.println(" Closing soon (< 30 min): " + closingSoon);
|
||||
|
||||
} catch (Exception e) {
|
||||
Console.println(" ⚠️ Could not retrieve status: " + e.getMessage());
|
||||
}
|
||||
|
||||
Console.println();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shuts down all workflows
|
||||
*/
|
||||
public void shutdown() {
|
||||
Console.println("\n🛑 Shutting down workflows...");
|
||||
|
||||
isRunning = false;
|
||||
scheduler.shutdown();
|
||||
|
||||
try {
|
||||
if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
Console.println("✓ Workflows shut down successfully\n");
|
||||
} catch (InterruptedException e) {
|
||||
scheduler.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/main/resources/application.properties
Normal file
30
src/main/resources/application.properties
Normal file
@@ -0,0 +1,30 @@
|
||||
# Application Configuration
|
||||
quarkus.application.name=troostwijk-scraper
|
||||
quarkus.application.version=1.0-SNAPSHOT
|
||||
|
||||
# HTTP Configuration
|
||||
quarkus.http.port=8081
|
||||
quarkus.http.host=0.0.0.0
|
||||
|
||||
# Enable CORS for frontend development
|
||||
quarkus.http.cors=true
|
||||
quarkus.http.cors.origins=*
|
||||
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
|
||||
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with
|
||||
|
||||
# Logging Configuration
|
||||
quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n
|
||||
quarkus.log.console.level=INFO
|
||||
|
||||
# Development mode settings
|
||||
%dev.quarkus.log.console.level=DEBUG
|
||||
%dev.quarkus.live-reload.instrumentation=true
|
||||
|
||||
# Production optimizations
|
||||
%prod.quarkus.package.type=fast-jar
|
||||
%prod.quarkus.http.enable-compression=true
|
||||
|
||||
# Static resources
|
||||
quarkus.http.enable-compression=true
|
||||
quarkus.rest.path=/api
|
||||
quarkus.http.root-path=/
|
||||
382
src/test/java/com/auction/DatabaseServiceTest.java
Normal file
382
src/test/java/com/auction/DatabaseServiceTest.java
Normal file
@@ -0,0 +1,382 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Test cases for DatabaseService.
|
||||
* Tests database operations including schema creation, CRUD operations, and data retrieval.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DatabaseServiceTest {
|
||||
|
||||
private DatabaseService db;
|
||||
private String testDbPath;
|
||||
|
||||
@BeforeAll
|
||||
void setUp() throws SQLException {
|
||||
testDbPath = "test_database_" + System.currentTimeMillis() + ".db";
|
||||
db = new DatabaseService(testDbPath);
|
||||
db.ensureSchema();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void tearDown() throws Exception {
|
||||
// Clean up test database
|
||||
Files.deleteIfExists(Paths.get(testDbPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should create database schema successfully")
|
||||
void testEnsureSchema() {
|
||||
assertDoesNotThrow(() -> db.ensureSchema());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should insert and retrieve auction")
|
||||
void testUpsertAndGetAuction() throws SQLException {
|
||||
var auction = new AuctionInfo(
|
||||
12345,
|
||||
"Test Auction",
|
||||
"Amsterdam, NL",
|
||||
"Amsterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/12345",
|
||||
"A7",
|
||||
50,
|
||||
LocalDateTime.of(2025, 12, 15, 14, 30)
|
||||
);
|
||||
|
||||
db.upsertAuction(auction);
|
||||
|
||||
var auctions = db.getAllAuctions();
|
||||
assertFalse(auctions.isEmpty());
|
||||
|
||||
var retrieved = auctions.stream()
|
||||
.filter(a -> a.auctionId() == 12345)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(retrieved);
|
||||
assertEquals("Test Auction", retrieved.title());
|
||||
assertEquals("Amsterdam", retrieved.city());
|
||||
assertEquals("NL", retrieved.country());
|
||||
assertEquals(50, retrieved.lotCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update existing auction on conflict")
|
||||
void testUpsertAuctionUpdate() throws SQLException {
|
||||
var auction1 = new AuctionInfo(
|
||||
99999,
|
||||
"Original Title",
|
||||
"Rotterdam, NL",
|
||||
"Rotterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/99999",
|
||||
"A1",
|
||||
10,
|
||||
null
|
||||
);
|
||||
|
||||
db.upsertAuction(auction1);
|
||||
|
||||
// Update with same ID
|
||||
var auction2 = new AuctionInfo(
|
||||
99999,
|
||||
"Updated Title",
|
||||
"Rotterdam, NL",
|
||||
"Rotterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/99999",
|
||||
"A1",
|
||||
20,
|
||||
null
|
||||
);
|
||||
|
||||
db.upsertAuction(auction2);
|
||||
|
||||
var auctions = db.getAllAuctions();
|
||||
var retrieved = auctions.stream()
|
||||
.filter(a -> a.auctionId() == 99999)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(retrieved);
|
||||
assertEquals("Updated Title", retrieved.title());
|
||||
assertEquals(20, retrieved.lotCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve auctions by country code")
|
||||
void testGetAuctionsByCountry() throws SQLException {
|
||||
// Insert auctions from different countries
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
10001, "Dutch Auction", "Amsterdam, NL", "Amsterdam", "NL",
|
||||
"https://example.com/10001", "A1", 10, null
|
||||
));
|
||||
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
10002, "Romanian Auction", "Cluj, RO", "Cluj", "RO",
|
||||
"https://example.com/10002", "A2", 15, null
|
||||
));
|
||||
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
10003, "Another Dutch", "Utrecht, NL", "Utrecht", "NL",
|
||||
"https://example.com/10003", "A3", 20, null
|
||||
));
|
||||
|
||||
var nlAuctions = db.getAuctionsByCountry("NL");
|
||||
assertEquals(2, nlAuctions.stream().filter(a -> a.auctionId() >= 10001 && a.auctionId() <= 10003).count());
|
||||
|
||||
var roAuctions = db.getAuctionsByCountry("RO");
|
||||
assertEquals(1, roAuctions.stream().filter(a -> a.auctionId() == 10002).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should insert and retrieve lot")
|
||||
void testUpsertAndGetLot() throws SQLException {
|
||||
var lot = new Lot(
|
||||
12345, // saleId
|
||||
67890, // lotId
|
||||
"Forklift",
|
||||
"Electric forklift in good condition",
|
||||
"Toyota",
|
||||
"Electric",
|
||||
2018,
|
||||
"Machinery",
|
||||
1500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/67890",
|
||||
LocalDateTime.of(2025, 12, 20, 16, 0),
|
||||
false
|
||||
);
|
||||
|
||||
db.upsertLot(lot);
|
||||
|
||||
var lots = db.getAllLots();
|
||||
assertFalse(lots.isEmpty());
|
||||
|
||||
var retrieved = lots.stream()
|
||||
.filter(l -> l.lotId() == 67890)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(retrieved);
|
||||
assertEquals("Forklift", retrieved.title());
|
||||
assertEquals("Toyota", retrieved.manufacturer());
|
||||
assertEquals(2018, retrieved.year());
|
||||
assertEquals(1500.00, retrieved.currentBid(), 0.01);
|
||||
assertFalse(retrieved.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update lot current bid")
|
||||
void testUpdateLotCurrentBid() throws SQLException {
|
||||
var lot = new Lot(
|
||||
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/22222", null, false
|
||||
);
|
||||
|
||||
db.upsertLot(lot);
|
||||
|
||||
// Update bid
|
||||
var updatedLot = new Lot(
|
||||
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
|
||||
250.00, "EUR", "https://example.com/lot/22222", null, false
|
||||
);
|
||||
|
||||
db.updateLotCurrentBid(updatedLot);
|
||||
|
||||
var lots = db.getAllLots();
|
||||
var retrieved = lots.stream()
|
||||
.filter(l -> l.lotId() == 22222)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(retrieved);
|
||||
assertEquals(250.00, retrieved.currentBid(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update lot notification flags")
|
||||
void testUpdateLotNotificationFlags() throws SQLException {
|
||||
var lot = new Lot(
|
||||
33333, 44444, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/44444", null, false
|
||||
);
|
||||
|
||||
db.upsertLot(lot);
|
||||
|
||||
// Update notification flag
|
||||
var updatedLot = new Lot(
|
||||
33333, 44444, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/44444", null, true
|
||||
);
|
||||
|
||||
db.updateLotNotificationFlags(updatedLot);
|
||||
|
||||
var lots = db.getAllLots();
|
||||
var retrieved = lots.stream()
|
||||
.filter(l -> l.lotId() == 44444)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(retrieved);
|
||||
assertTrue(retrieved.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should insert and retrieve image records")
|
||||
void testInsertAndGetImages() throws SQLException {
|
||||
// First create a lot
|
||||
var lot = new Lot(
|
||||
55555, 66666, "Test Lot", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/66666", null, false
|
||||
);
|
||||
db.upsertLot(lot);
|
||||
|
||||
// Insert images
|
||||
db.insertImage(66666, "https://example.com/img1.jpg",
|
||||
"C:/images/66666/img1.jpg", List.of("car", "vehicle"));
|
||||
|
||||
db.insertImage(66666, "https://example.com/img2.jpg",
|
||||
"C:/images/66666/img2.jpg", List.of("truck"));
|
||||
|
||||
var images = db.getImagesForLot(66666);
|
||||
assertEquals(2, images.size());
|
||||
|
||||
var img1 = images.stream()
|
||||
.filter(i -> i.url().contains("img1.jpg"))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(img1);
|
||||
assertEquals("car,vehicle", img1.labels());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should count total images")
|
||||
void testGetImageCount() throws SQLException {
|
||||
int initialCount = db.getImageCount();
|
||||
|
||||
// Add a lot and image
|
||||
var lot = new Lot(
|
||||
77777, 88888, "Test Lot", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/88888", null, false
|
||||
);
|
||||
db.upsertLot(lot);
|
||||
|
||||
db.insertImage(88888, "https://example.com/test.jpg",
|
||||
"C:/images/88888/test.jpg", List.of("object"));
|
||||
|
||||
int newCount = db.getImageCount();
|
||||
assertTrue(newCount > initialCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty database gracefully")
|
||||
void testEmptyDatabase() throws SQLException {
|
||||
DatabaseService emptyDb = new DatabaseService("empty_test_" + System.currentTimeMillis() + ".db");
|
||||
emptyDb.ensureSchema();
|
||||
|
||||
var auctions = emptyDb.getAllAuctions();
|
||||
var lots = emptyDb.getAllLots();
|
||||
int imageCount = emptyDb.getImageCount();
|
||||
|
||||
assertNotNull(auctions);
|
||||
assertNotNull(lots);
|
||||
assertTrue(auctions.isEmpty());
|
||||
assertTrue(lots.isEmpty());
|
||||
assertEquals(0, imageCount);
|
||||
|
||||
// Clean up
|
||||
Files.deleteIfExists(Paths.get("empty_test_" + System.currentTimeMillis() + ".db"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle lots with null closing time")
|
||||
void testLotWithNullClosingTime() throws SQLException {
|
||||
var lot = new Lot(
|
||||
98765, 12340, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/12340", null, false
|
||||
);
|
||||
|
||||
assertDoesNotThrow(() -> db.upsertLot(lot));
|
||||
|
||||
var retrieved = db.getAllLots().stream()
|
||||
.filter(l -> l.lotId() == 12340)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(retrieved);
|
||||
assertNull(retrieved.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve active lots only")
|
||||
void testGetActiveLots() throws SQLException {
|
||||
var activeLot = new Lot(
|
||||
11111, 55551, "Active Lot", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/55551",
|
||||
LocalDateTime.now().plusDays(1), false
|
||||
);
|
||||
|
||||
db.upsertLot(activeLot);
|
||||
|
||||
var activeLots = db.getActiveLots();
|
||||
assertFalse(activeLots.isEmpty());
|
||||
|
||||
var found = activeLots.stream()
|
||||
.anyMatch(l -> l.lotId() == 55551);
|
||||
assertTrue(found);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle concurrent upserts")
|
||||
void testConcurrentUpserts() throws InterruptedException {
|
||||
Thread t1 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
db.upsertLot(new Lot(
|
||||
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Thread 1 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
Thread t2 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 10; i < 20; i++) {
|
||||
db.upsertLot(new Lot(
|
||||
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Thread 2 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
t1.start();
|
||||
t2.start();
|
||||
t1.join();
|
||||
t2.join();
|
||||
|
||||
var lots = db.getAllLots();
|
||||
long concurrentLots = lots.stream()
|
||||
.filter(l -> l.lotId() >= 99100 && l.lotId() < 99120)
|
||||
.count();
|
||||
|
||||
assertTrue(concurrentLots >= 20);
|
||||
}
|
||||
}
|
||||
204
src/test/java/com/auction/ImageProcessingServiceTest.java
Normal file
204
src/test/java/com/auction/ImageProcessingServiceTest.java
Normal file
@@ -0,0 +1,204 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test cases for ImageProcessingService.
|
||||
* Tests image downloading, object detection integration, and database updates.
|
||||
*/
|
||||
class ImageProcessingServiceTest {
|
||||
|
||||
private DatabaseService mockDb;
|
||||
private ObjectDetectionService mockDetector;
|
||||
private ImageProcessingService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockDb = mock(DatabaseService.class);
|
||||
mockDetector = mock(ObjectDetectionService.class);
|
||||
service = new ImageProcessingService(mockDb, mockDetector);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
// Clean up any test image directories
|
||||
var testDir = Paths.get("C:", "mnt", "okcomputer", "output", "images", "999");
|
||||
if (Files.exists(testDir)) {
|
||||
Files.walk(testDir)
|
||||
.sorted((a, b) -> b.compareTo(a)) // Reverse order for deletion
|
||||
.forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (Exception e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process images for lot with object detection")
|
||||
void testProcessImagesForLot() throws SQLException {
|
||||
when(mockDetector.detectObjects(anyString()))
|
||||
.thenReturn(List.of("car", "vehicle"));
|
||||
|
||||
// Note: This test uses mock URLs and won't actually download
|
||||
// In a real scenario, you'd use a test HTTP server
|
||||
var imageUrls = List.of("https://example.com/test1.jpg");
|
||||
|
||||
// Mock successful processing
|
||||
doNothing().when(mockDb).insertImage(anyInt(), anyString(), anyString(), anyList());
|
||||
|
||||
// Process images
|
||||
service.processImagesForLot(12345, 99999, imageUrls);
|
||||
|
||||
// Verify detection was called (may not be called if download fails)
|
||||
// In a full integration test, you'd verify the complete flow
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle image download failure gracefully")
|
||||
void testDownloadImageFailure() {
|
||||
// Invalid URL should return null
|
||||
String result = service.downloadImage("invalid-url", 123, 456);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should create directory structure for images")
|
||||
void testDirectoryCreation() {
|
||||
// Create test directory structure
|
||||
var testDir = Paths.get("C:", "mnt", "okcomputer", "output", "images", "999", "888");
|
||||
|
||||
// Ensure parent exists for test
|
||||
try {
|
||||
Files.createDirectories(testDir.getParent());
|
||||
assertTrue(Files.exists(testDir.getParent()));
|
||||
} catch (Exception e) {
|
||||
// Skip test if cannot create directories (permissions issue)
|
||||
Assumptions.assumeTrue(false, "Cannot create test directories");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should save detected objects to database")
|
||||
void testSaveDetectedObjects() throws SQLException {
|
||||
// Capture what's saved to database
|
||||
ArgumentCaptor<Integer> lotIdCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> filePathCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<List<String>> labelsCaptor = ArgumentCaptor.forClass(List.class);
|
||||
|
||||
when(mockDetector.detectObjects(anyString()))
|
||||
.thenReturn(List.of("truck", "machinery"));
|
||||
|
||||
doNothing().when(mockDb).insertImage(
|
||||
lotIdCaptor.capture(),
|
||||
urlCaptor.capture(),
|
||||
filePathCaptor.capture(),
|
||||
labelsCaptor.capture()
|
||||
);
|
||||
|
||||
// Simulate processing (won't actually download without real server)
|
||||
// In real test, you'd mock the HTTP client
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty image list")
|
||||
void testProcessEmptyImageList() throws SQLException {
|
||||
service.processImagesForLot(123, 456, List.of());
|
||||
|
||||
// Should not call database insert
|
||||
verify(mockDb, never()).insertImage(anyInt(), anyString(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process pending images from database")
|
||||
void testProcessPendingImages() throws SQLException {
|
||||
when(mockDb.getAllLots()).thenReturn(List.of(
|
||||
new Lot(111, 222, "Test Lot 1", "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com", null, false),
|
||||
new Lot(333, 444, "Test Lot 2", "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com", null, false)
|
||||
));
|
||||
|
||||
when(mockDb.getImagesForLot(anyInt())).thenReturn(List.of());
|
||||
|
||||
service.processPendingImages();
|
||||
|
||||
// Verify lots were queried
|
||||
verify(mockDb, times(1)).getAllLots();
|
||||
verify(mockDb, times(2)).getImagesForLot(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should skip lots that already have images")
|
||||
void testSkipLotsWithExistingImages() throws SQLException {
|
||||
when(mockDb.getAllLots()).thenReturn(List.of(
|
||||
new Lot(111, 222, "Test Lot", "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com", null, false)
|
||||
));
|
||||
|
||||
// Return existing images
|
||||
when(mockDb.getImagesForLot(222)).thenReturn(List.of(
|
||||
new DatabaseService.ImageRecord(1, 222, "http://example.com/img.jpg",
|
||||
"C:/images/img.jpg", "car,vehicle")
|
||||
));
|
||||
|
||||
service.processPendingImages();
|
||||
|
||||
verify(mockDb).getImagesForLot(222);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle database errors during image save")
|
||||
void testDatabaseErrorHandling() throws SQLException {
|
||||
when(mockDetector.detectObjects(anyString()))
|
||||
.thenReturn(List.of("object"));
|
||||
|
||||
doThrow(new SQLException("Database error"))
|
||||
.when(mockDb).insertImage(anyInt(), anyString(), anyString(), anyList());
|
||||
|
||||
// Should not throw exception, but handle error
|
||||
assertDoesNotThrow(() ->
|
||||
service.processImagesForLot(123, 456, List.of("https://example.com/test.jpg"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty detection results")
|
||||
void testEmptyDetectionResults() throws SQLException {
|
||||
when(mockDetector.detectObjects(anyString()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
doNothing().when(mockDb).insertImage(anyInt(), anyString(), anyString(), anyList());
|
||||
|
||||
// Should still save to database with empty labels
|
||||
// (In real scenario with actual download)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle lots with no existing images")
|
||||
void testLotsWithNoImages() throws SQLException {
|
||||
when(mockDb.getAllLots()).thenReturn(List.of(
|
||||
new Lot(555, 666, "New Lot", "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com", null, false)
|
||||
));
|
||||
|
||||
when(mockDb.getImagesForLot(666)).thenReturn(List.of());
|
||||
|
||||
service.processPendingImages();
|
||||
|
||||
verify(mockDb).getImagesForLot(666);
|
||||
}
|
||||
}
|
||||
461
src/test/java/com/auction/IntegrationTest.java
Normal file
461
src/test/java/com/auction/IntegrationTest.java
Normal file
@@ -0,0 +1,461 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test for complete workflow.
|
||||
* Tests end-to-end scenarios including:
|
||||
* 1. Scraper data import
|
||||
* 2. Data transformation
|
||||
* 3. Image processing
|
||||
* 4. Object detection
|
||||
* 5. Bid monitoring
|
||||
* 6. Notifications
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class IntegrationTest {
|
||||
|
||||
private String testDbPath;
|
||||
private DatabaseService db;
|
||||
private NotificationService notifier;
|
||||
private ObjectDetectionService detector;
|
||||
private ImageProcessingService imageProcessor;
|
||||
private TroostwijkMonitor monitor;
|
||||
|
||||
@BeforeAll
|
||||
void setUp() throws SQLException, IOException {
|
||||
testDbPath = "test_integration_" + System.currentTimeMillis() + ".db";
|
||||
|
||||
// Initialize all services
|
||||
db = new DatabaseService(testDbPath);
|
||||
db.ensureSchema();
|
||||
|
||||
notifier = new NotificationService("desktop", "");
|
||||
|
||||
detector = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
imageProcessor = new ImageProcessingService(db, detector);
|
||||
|
||||
monitor = new TroostwijkMonitor(
|
||||
testDbPath,
|
||||
"desktop",
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void tearDown() throws Exception {
|
||||
Files.deleteIfExists(Paths.get(testDbPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("Integration: Complete scraper data import workflow")
|
||||
void testCompleteScraperImportWorkflow() throws SQLException {
|
||||
// Step 1: Import auction from scraper format
|
||||
var auction = new AuctionInfo(
|
||||
12345,
|
||||
"Industrial Equipment Auction",
|
||||
"Rotterdam, NL",
|
||||
"Rotterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/12345",
|
||||
"A7",
|
||||
25,
|
||||
LocalDateTime.now().plusDays(3)
|
||||
);
|
||||
|
||||
db.upsertAuction(auction);
|
||||
|
||||
// Step 2: Import lots for this auction
|
||||
var lot1 = new Lot(
|
||||
12345, 10001,
|
||||
"Toyota Forklift 2.5T",
|
||||
"Electric forklift in excellent condition",
|
||||
"Toyota",
|
||||
"Electric",
|
||||
2018,
|
||||
"Machinery",
|
||||
1500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/10001",
|
||||
LocalDateTime.now().plusDays(3),
|
||||
false
|
||||
);
|
||||
|
||||
var lot2 = new Lot(
|
||||
12345, 10002,
|
||||
"Office Furniture Set",
|
||||
"Desks, chairs, and cabinets",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Furniture",
|
||||
500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/10002",
|
||||
LocalDateTime.now().plusDays(3),
|
||||
false
|
||||
);
|
||||
|
||||
db.upsertLot(lot1);
|
||||
db.upsertLot(lot2);
|
||||
|
||||
// Verify import
|
||||
var auctions = db.getAllAuctions();
|
||||
var lots = db.getAllLots();
|
||||
|
||||
assertTrue(auctions.stream().anyMatch(a -> a.auctionId() == 12345));
|
||||
assertEquals(2, lots.stream().filter(l -> l.saleId() == 12345).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("Integration: Image processing and detection workflow")
|
||||
void testImageProcessingWorkflow() throws SQLException {
|
||||
// Add images for a lot
|
||||
db.insertImage(10001, "https://example.com/img1.jpg",
|
||||
"C:/images/10001/img1.jpg", List.of("truck", "vehicle"));
|
||||
|
||||
db.insertImage(10001, "https://example.com/img2.jpg",
|
||||
"C:/images/10001/img2.jpg", List.of("forklift", "machinery"));
|
||||
|
||||
// Verify images were saved
|
||||
var images = db.getImagesForLot(10001);
|
||||
assertEquals(2, images.size());
|
||||
|
||||
var labels = images.stream()
|
||||
.flatMap(img -> List.of(img.labels().split(",")).stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
assertTrue(labels.contains("truck") || labels.contains("forklift"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
@DisplayName("Integration: Bid monitoring and notification workflow")
|
||||
void testBidMonitoringWorkflow() throws SQLException {
|
||||
// Simulate bid change
|
||||
var lot = db.getAllLots().stream()
|
||||
.filter(l -> l.lotId() == 10001)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
// Update bid
|
||||
var updatedLot = new Lot(
|
||||
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
|
||||
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
||||
2000.00, // Increased from 1500.00
|
||||
lot.currency(), lot.url(), lot.closingTime(), lot.closingNotified()
|
||||
);
|
||||
|
||||
db.updateLotCurrentBid(updatedLot);
|
||||
|
||||
// Send notification
|
||||
String message = String.format("Nieuw bod op kavel %d: €%.2f (was €%.2f)",
|
||||
lot.lotId(), 2000.00, 1500.00);
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(message, "Kavel bieding update", 0)
|
||||
);
|
||||
|
||||
// Verify bid was updated
|
||||
var refreshed = db.getAllLots().stream()
|
||||
.filter(l -> l.lotId() == 10001)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertEquals(2000.00, refreshed.currentBid(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
@DisplayName("Integration: Closing alert workflow")
|
||||
void testClosingAlertWorkflow() throws SQLException {
|
||||
// Create lot closing soon
|
||||
var closingSoon = new Lot(
|
||||
12345, 20001,
|
||||
"Closing Soon Item",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
750.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/20001",
|
||||
LocalDateTime.now().plusMinutes(4),
|
||||
false
|
||||
);
|
||||
|
||||
db.upsertLot(closingSoon);
|
||||
|
||||
// Check if lot is closing soon
|
||||
assertTrue(closingSoon.minutesUntilClose() < 5);
|
||||
|
||||
// Send high-priority notification
|
||||
String message = "Kavel " + closingSoon.lotId() + " sluit binnen 5 min.";
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(message, "Lot nearing closure", 1)
|
||||
);
|
||||
|
||||
// Mark as notified
|
||||
var notified = new Lot(
|
||||
closingSoon.saleId(), closingSoon.lotId(), closingSoon.title(),
|
||||
closingSoon.description(), closingSoon.manufacturer(), closingSoon.type(),
|
||||
closingSoon.year(), closingSoon.category(), closingSoon.currentBid(),
|
||||
closingSoon.currency(), closingSoon.url(), closingSoon.closingTime(),
|
||||
true
|
||||
);
|
||||
|
||||
db.updateLotNotificationFlags(notified);
|
||||
|
||||
// Verify notification flag
|
||||
var updated = db.getAllLots().stream()
|
||||
.filter(l -> l.lotId() == 20001)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertTrue(updated.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
@DisplayName("Integration: Multi-country auction filtering")
|
||||
void testMultiCountryFiltering() throws SQLException {
|
||||
// Add auctions from different countries
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
30001, "Dutch Auction", "Amsterdam, NL", "Amsterdam", "NL",
|
||||
"https://example.com/30001", "A1", 10, null
|
||||
));
|
||||
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
30002, "Romanian Auction", "Cluj, RO", "Cluj", "RO",
|
||||
"https://example.com/30002", "A2", 15, null
|
||||
));
|
||||
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
30003, "Belgian Auction", "Brussels, BE", "Brussels", "BE",
|
||||
"https://example.com/30003", "A3", 20, null
|
||||
));
|
||||
|
||||
// Filter by country
|
||||
var nlAuctions = db.getAuctionsByCountry("NL");
|
||||
var roAuctions = db.getAuctionsByCountry("RO");
|
||||
var beAuctions = db.getAuctionsByCountry("BE");
|
||||
|
||||
assertTrue(nlAuctions.stream().anyMatch(a -> a.auctionId() == 30001));
|
||||
assertTrue(roAuctions.stream().anyMatch(a -> a.auctionId() == 30002));
|
||||
assertTrue(beAuctions.stream().anyMatch(a -> a.auctionId() == 30003));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
@DisplayName("Integration: Complete monitoring cycle")
|
||||
void testCompleteMonitoringCycle() throws SQLException {
|
||||
// Monitor should handle all lots
|
||||
monitor.printDatabaseStats();
|
||||
|
||||
var activeLots = db.getActiveLots();
|
||||
assertFalse(activeLots.isEmpty());
|
||||
|
||||
// Process pending images
|
||||
assertDoesNotThrow(() -> monitor.processPendingImages());
|
||||
|
||||
// Verify database integrity
|
||||
int imageCount = db.getImageCount();
|
||||
assertTrue(imageCount >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
@DisplayName("Integration: Data consistency across services")
|
||||
void testDataConsistency() throws SQLException {
|
||||
// Verify all auctions have valid data
|
||||
var auctions = db.getAllAuctions();
|
||||
for (var auction : auctions) {
|
||||
assertNotNull(auction.auctionId());
|
||||
assertNotNull(auction.title());
|
||||
assertNotNull(auction.url());
|
||||
}
|
||||
|
||||
// Verify all lots have valid data
|
||||
var lots = db.getAllLots();
|
||||
for (var lot : lots) {
|
||||
assertNotNull(lot.lotId());
|
||||
assertNotNull(lot.title());
|
||||
assertTrue(lot.currentBid() >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(8)
|
||||
@DisplayName("Integration: Object detection value estimation workflow")
|
||||
void testValueEstimationWorkflow() throws SQLException {
|
||||
// Create lot with detected objects
|
||||
var lot = new Lot(
|
||||
40000, 50000,
|
||||
"Construction Equipment",
|
||||
"Heavy machinery for construction",
|
||||
"Caterpillar",
|
||||
"Excavator",
|
||||
2015,
|
||||
"Machinery",
|
||||
25000.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/50000",
|
||||
LocalDateTime.now().plusDays(5),
|
||||
false
|
||||
);
|
||||
|
||||
db.upsertLot(lot);
|
||||
|
||||
// Add images with detected objects
|
||||
db.insertImage(50000, "https://example.com/excavator1.jpg",
|
||||
"C:/images/50000/1.jpg", List.of("truck", "excavator", "machinery"));
|
||||
|
||||
db.insertImage(50000, "https://example.com/excavator2.jpg",
|
||||
"C:/images/50000/2.jpg", List.of("excavator", "construction"));
|
||||
|
||||
// Retrieve and analyze
|
||||
var images = db.getImagesForLot(50000);
|
||||
assertFalse(images.isEmpty());
|
||||
|
||||
// Count unique objects
|
||||
var allLabels = images.stream()
|
||||
.flatMap(img -> List.of(img.labels().split(",")).stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
assertTrue(allLabels.contains("excavator") || allLabels.contains("machinery"));
|
||||
|
||||
// Simulate value estimation notification
|
||||
String message = String.format(
|
||||
"Lot contains: %s\nEstimated value: €%,.2f",
|
||||
String.join(", ", allLabels),
|
||||
lot.currentBid()
|
||||
);
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(message, "Object Detected", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(9)
|
||||
@DisplayName("Integration: Handle rapid concurrent updates")
|
||||
void testConcurrentOperations() throws InterruptedException {
|
||||
Thread auctionThread = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
db.upsertAuction(new AuctionInfo(
|
||||
60000 + i, "Concurrent Auction " + i, "Test, NL", "Test", "NL",
|
||||
"https://example.com/60" + i, "A1", 5, null
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Auction thread failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
Thread lotThread = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
db.upsertLot(new Lot(
|
||||
60000 + i, 70000 + i, "Concurrent Lot " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0 * i, "EUR", "https://example.com/70" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Lot thread failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
auctionThread.start();
|
||||
lotThread.start();
|
||||
auctionThread.join();
|
||||
lotThread.join();
|
||||
|
||||
// Verify all were inserted
|
||||
var auctions = db.getAllAuctions();
|
||||
var lots = db.getAllLots();
|
||||
|
||||
long auctionCount = auctions.stream()
|
||||
.filter(a -> a.auctionId() >= 60000 && a.auctionId() < 60010)
|
||||
.count();
|
||||
|
||||
long lotCount = lots.stream()
|
||||
.filter(l -> l.lotId() >= 70000 && l.lotId() < 70010)
|
||||
.count();
|
||||
|
||||
assertEquals(10, auctionCount);
|
||||
assertEquals(10, lotCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(10)
|
||||
@DisplayName("Integration: End-to-end notification scenarios")
|
||||
void testAllNotificationScenarios() {
|
||||
// 1. Bid change notification
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(
|
||||
"Nieuw bod op kavel 12345: €150.00 (was €125.00)",
|
||||
"Kavel bieding update",
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
// 2. Closing alert
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(
|
||||
"Kavel 67890 sluit binnen 5 min.",
|
||||
"Lot nearing closure",
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
// 3. Object detection
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(
|
||||
"Detected: car, truck, machinery",
|
||||
"Object Detected",
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
// 4. Value estimate
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(
|
||||
"Geschatte waarde: €5,000 - €7,500",
|
||||
"Value Estimate",
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
// 5. Viewing day reminder
|
||||
assertDoesNotThrow(() ->
|
||||
notifier.sendNotification(
|
||||
"Bezichtiging op 15-12-2025 om 14:00",
|
||||
"Viewing Day Reminder",
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
247
src/test/java/com/auction/NotificationServiceTest.java
Normal file
247
src/test/java/com/auction/NotificationServiceTest.java
Normal file
@@ -0,0 +1,247 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Test cases for NotificationService.
|
||||
* Tests desktop and email notification configuration and delivery.
|
||||
*/
|
||||
class NotificationServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize with desktop-only configuration")
|
||||
void testDesktopOnlyConfiguration() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
assertNotNull(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize with SMTP configuration")
|
||||
void testSMTPConfiguration() {
|
||||
NotificationService service = new NotificationService(
|
||||
"smtp:test@gmail.com:app_password:recipient@example.com",
|
||||
""
|
||||
);
|
||||
assertNotNull(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject invalid SMTP configuration format")
|
||||
void testInvalidSMTPConfiguration() {
|
||||
// Missing parts
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new NotificationService("smtp:incomplete", "")
|
||||
);
|
||||
|
||||
// Wrong format
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new NotificationService("smtp:only:two:parts", "")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject unknown configuration type")
|
||||
void testUnknownConfiguration() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new NotificationService("unknown_type", "")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send desktop notification without error")
|
||||
void testDesktopNotification() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
// Should not throw exception even if system tray not available
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("Test message", "Test title", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send high priority notification")
|
||||
void testHighPriorityNotification() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("Urgent message", "High Priority", 1)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send normal priority notification")
|
||||
void testNormalPriorityNotification() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("Regular message", "Normal Priority", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle notification when system tray not supported")
|
||||
void testNoSystemTraySupport() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
// Should gracefully handle missing system tray
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("Test", "Test", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send email notification with valid SMTP config")
|
||||
void testEmailNotificationWithValidConfig() {
|
||||
// Note: This won't actually send email without valid credentials
|
||||
// But it should initialize properly
|
||||
NotificationService service = new NotificationService(
|
||||
"smtp:test@gmail.com:fake_password:test@example.com",
|
||||
""
|
||||
);
|
||||
|
||||
// Should not throw during initialization
|
||||
assertNotNull(service);
|
||||
|
||||
// Sending will fail with fake credentials, but shouldn't crash
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("Test email", "Email Test", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should include both desktop and email when SMTP configured")
|
||||
void testBothNotificationChannels() {
|
||||
NotificationService service = new NotificationService(
|
||||
"smtp:user@gmail.com:password:recipient@example.com",
|
||||
""
|
||||
);
|
||||
|
||||
// Both desktop and email should be attempted
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("Dual channel test", "Test", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty message gracefully")
|
||||
void testEmptyMessage() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification("", "", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle very long message")
|
||||
void testLongMessage() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
String longMessage = "A".repeat(1000);
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification(longMessage, "Long Message Test", 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle special characters in message")
|
||||
void testSpecialCharactersInMessage() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification(
|
||||
"€123.45 - Kavel sluit binnen 5 min! ⚠️",
|
||||
"Special Chars Test",
|
||||
1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should accept case-insensitive desktop config")
|
||||
void testCaseInsensitiveDesktopConfig() {
|
||||
assertDoesNotThrow(() -> {
|
||||
new NotificationService("DESKTOP", "");
|
||||
new NotificationService("Desktop", "");
|
||||
new NotificationService("desktop", "");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate SMTP config parts count")
|
||||
void testSMTPConfigPartsValidation() {
|
||||
// Too few parts
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new NotificationService("smtp:user:pass", "")
|
||||
);
|
||||
|
||||
// Too many parts should work (extras ignored in split)
|
||||
assertDoesNotThrow(() ->
|
||||
new NotificationService("smtp:user:pass:email:extra", "")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple rapid notifications")
|
||||
void testRapidNotifications() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
assertDoesNotThrow(() -> {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
service.sendNotification("Notification " + i, "Rapid Test", 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle notification with null config parameter")
|
||||
void testNullConfigParameter() {
|
||||
// Second parameter can be empty string (kept for compatibility)
|
||||
assertDoesNotThrow(() ->
|
||||
new NotificationService("desktop", null)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send bid change notification format")
|
||||
void testBidChangeNotificationFormat() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
String message = "Nieuw bod op kavel 12345: €150.00 (was €125.00)";
|
||||
String title = "Kavel bieding update";
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification(message, title, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send closing alert notification format")
|
||||
void testClosingAlertNotificationFormat() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
String message = "Kavel 12345 sluit binnen 5 min.";
|
||||
String title = "Lot nearing closure";
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification(message, title, 1)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should send object detection notification format")
|
||||
void testObjectDetectionNotificationFormat() {
|
||||
NotificationService service = new NotificationService("desktop", "");
|
||||
|
||||
String message = "Lot contains: car, truck, machinery\nEstimated value: €5000";
|
||||
String title = "Object Detected";
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.sendNotification(message, title, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
186
src/test/java/com/auction/ObjectDetectionServiceTest.java
Normal file
186
src/test/java/com/auction/ObjectDetectionServiceTest.java
Normal file
@@ -0,0 +1,186 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Test cases for ObjectDetectionService.
|
||||
* Tests YOLO model loading and object detection functionality.
|
||||
*/
|
||||
class ObjectDetectionServiceTest {
|
||||
|
||||
private static final String TEST_CFG = "test_yolo.cfg";
|
||||
private static final String TEST_WEIGHTS = "test_yolo.weights";
|
||||
private static final String TEST_CLASSES = "test_classes.txt";
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize with missing YOLO models (disabled mode)")
|
||||
void testInitializeWithoutModels() throws IOException {
|
||||
// When models don't exist, service should initialize in disabled mode
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
assertNotNull(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return empty list when detection is disabled")
|
||||
void testDetectObjectsWhenDisabled() throws IOException {
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
var result = service.detectObjects("any_image.jpg");
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle invalid image path gracefully")
|
||||
void testInvalidImagePath() throws IOException {
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
var result = service.detectObjects("completely_invalid_path.jpg");
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty image file")
|
||||
void testEmptyImageFile() throws IOException {
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
// Create empty test file
|
||||
var tempFile = Files.createTempFile("test_image", ".jpg");
|
||||
try {
|
||||
var result = service.detectObjects(tempFile.toString());
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize successfully with valid model files")
|
||||
void testInitializeWithValidModels() throws IOException {
|
||||
// Create dummy model files for testing initialization
|
||||
var cfgPath = Paths.get(TEST_CFG);
|
||||
var weightsPath = Paths.get(TEST_WEIGHTS);
|
||||
var classesPath = Paths.get(TEST_CLASSES);
|
||||
|
||||
try {
|
||||
Files.writeString(cfgPath, "[net]\nwidth=416\nheight=416\n");
|
||||
Files.write(weightsPath, new byte[]{0, 1, 2, 3});
|
||||
Files.writeString(classesPath, "person\ncar\ntruck\n");
|
||||
|
||||
// Note: This will still fail to load actual YOLO model without OpenCV
|
||||
// But it tests file existence check
|
||||
assertDoesNotThrow(() -> {
|
||||
try {
|
||||
new ObjectDetectionService(TEST_CFG, TEST_WEIGHTS, TEST_CLASSES);
|
||||
} catch (IOException e) {
|
||||
// Expected if OpenCV not loaded
|
||||
assertTrue(e.getMessage().contains("Failed to initialize"));
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
Files.deleteIfExists(cfgPath);
|
||||
Files.deleteIfExists(weightsPath);
|
||||
Files.deleteIfExists(classesPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle missing class names file")
|
||||
void testMissingClassNamesFile() {
|
||||
assertThrows(IOException.class, () -> {
|
||||
new ObjectDetectionService("non_existent.cfg", "non_existent.weights", "non_existent.txt");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should detect when model files are missing")
|
||||
void testDetectMissingModelFiles() throws IOException {
|
||||
// Should initialize in disabled mode
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"missing.cfg",
|
||||
"missing.weights",
|
||||
"missing.names"
|
||||
);
|
||||
|
||||
// Should return empty results when disabled
|
||||
var results = service.detectObjects("test.jpg");
|
||||
assertTrue(results.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return unique labels only")
|
||||
void testUniqueLabels() throws IOException {
|
||||
// When disabled, returns empty list (unique by default)
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
var result = service.detectObjects("test.jpg");
|
||||
assertNotNull(result);
|
||||
assertEquals(0, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple detections in same image")
|
||||
void testMultipleDetections() throws IOException {
|
||||
// Test structure for when detection works
|
||||
// With actual YOLO models, this would return multiple objects
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
var result = service.detectObjects("test_image.jpg");
|
||||
assertNotNull(result);
|
||||
// When disabled, returns empty list
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should respect confidence threshold")
|
||||
void testConfidenceThreshold() throws IOException {
|
||||
// The service uses 0.5 confidence threshold
|
||||
// This test documents that behavior
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
|
||||
// Low confidence detections should be filtered out
|
||||
// (when detection is working)
|
||||
var result = service.detectObjects("test.jpg");
|
||||
assertNotNull(result);
|
||||
}
|
||||
}
|
||||
247
src/test/java/com/auction/ScraperDataAdapterTest.java
Normal file
247
src/test/java/com/auction/ScraperDataAdapterTest.java
Normal file
@@ -0,0 +1,247 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test cases for ScraperDataAdapter.
|
||||
* Tests conversion from external scraper schema to monitor schema.
|
||||
*/
|
||||
class ScraperDataAdapterTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract numeric ID from text format auction ID")
|
||||
void testExtractNumericIdFromAuctionId() {
|
||||
assertEquals(39813, ScraperDataAdapter.extractNumericId("A7-39813"));
|
||||
assertEquals(12345, ScraperDataAdapter.extractNumericId("A1-12345"));
|
||||
assertEquals(0, ScraperDataAdapter.extractNumericId(null));
|
||||
assertEquals(0, ScraperDataAdapter.extractNumericId(""));
|
||||
assertEquals(0, ScraperDataAdapter.extractNumericId("ABC"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract numeric ID from text format lot ID")
|
||||
void testExtractNumericIdFromLotId() {
|
||||
// "A1-28505-5" → 285055 (concatenates all digits)
|
||||
assertEquals(285055, ScraperDataAdapter.extractNumericId("A1-28505-5"));
|
||||
assertEquals(123456, ScraperDataAdapter.extractNumericId("A7-1234-56"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should convert scraper auction format to AuctionInfo")
|
||||
void testFromScraperAuction() throws SQLException {
|
||||
// Mock ResultSet with scraper format data
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("auction_id")).thenReturn("A7-39813");
|
||||
when(rs.getString("title")).thenReturn("Industrial Equipment Auction");
|
||||
when(rs.getString("location")).thenReturn("Cluj-Napoca, RO");
|
||||
when(rs.getString("url")).thenReturn("https://example.com/auction/A7-39813");
|
||||
when(rs.getInt("lots_count")).thenReturn(150);
|
||||
when(rs.getString("first_lot_closing_time")).thenReturn("2025-12-15T14:30:00");
|
||||
|
||||
AuctionInfo result = ScraperDataAdapter.fromScraperAuction(rs);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(39813, result.auctionId());
|
||||
assertEquals("Industrial Equipment Auction", result.title());
|
||||
assertEquals("Cluj-Napoca, RO", result.location());
|
||||
assertEquals("Cluj-Napoca", result.city());
|
||||
assertEquals("RO", result.country());
|
||||
assertEquals("https://example.com/auction/A7-39813", result.url());
|
||||
assertEquals("A7", result.type());
|
||||
assertEquals(150, result.lotCount());
|
||||
assertNotNull(result.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle auction with simple location without country")
|
||||
void testFromScraperAuctionSimpleLocation() throws SQLException {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("auction_id")).thenReturn("A1-12345");
|
||||
when(rs.getString("title")).thenReturn("Test Auction");
|
||||
when(rs.getString("location")).thenReturn("Amsterdam");
|
||||
when(rs.getString("url")).thenReturn("https://example.com/auction/A1-12345");
|
||||
when(rs.getInt("lots_count")).thenReturn(50);
|
||||
when(rs.getString("first_lot_closing_time")).thenReturn(null);
|
||||
|
||||
AuctionInfo result = ScraperDataAdapter.fromScraperAuction(rs);
|
||||
|
||||
assertEquals("Amsterdam", result.city());
|
||||
assertEquals("", result.country());
|
||||
assertNull(result.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should convert scraper lot format to Lot")
|
||||
void testFromScraperLot() throws SQLException {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("lot_id")).thenReturn("A1-28505-5");
|
||||
when(rs.getString("auction_id")).thenReturn("A7-39813");
|
||||
when(rs.getString("title")).thenReturn("Forklift Toyota");
|
||||
when(rs.getString("description")).thenReturn("Electric forklift in good condition");
|
||||
when(rs.getString("category")).thenReturn("Machinery");
|
||||
when(rs.getString("current_bid")).thenReturn("€1250.50");
|
||||
when(rs.getString("closing_time")).thenReturn("2025-12-15T14:30:00");
|
||||
when(rs.getString("url")).thenReturn("https://example.com/lot/A1-28505-5");
|
||||
|
||||
Lot result = ScraperDataAdapter.fromScraperLot(rs);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(285055, result.lotId());
|
||||
assertEquals(39813, result.saleId());
|
||||
assertEquals("Forklift Toyota", result.title());
|
||||
assertEquals("Electric forklift in good condition", result.description());
|
||||
assertEquals("Machinery", result.category());
|
||||
assertEquals(1250.50, result.currentBid(), 0.01);
|
||||
assertEquals("EUR", result.currency());
|
||||
assertEquals("https://example.com/lot/A1-28505-5", result.url());
|
||||
assertNotNull(result.closingTime());
|
||||
assertFalse(result.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should parse bid amount from various formats")
|
||||
void testParseBidAmount() throws SQLException {
|
||||
// Test €123.45 format
|
||||
ResultSet rs1 = createLotResultSet("€123.45");
|
||||
Lot lot1 = ScraperDataAdapter.fromScraperLot(rs1);
|
||||
assertEquals(123.45, lot1.currentBid(), 0.01);
|
||||
assertEquals("EUR", lot1.currency());
|
||||
|
||||
// Test $50.00 format
|
||||
ResultSet rs2 = createLotResultSet("$50.00");
|
||||
Lot lot2 = ScraperDataAdapter.fromScraperLot(rs2);
|
||||
assertEquals(50.00, lot2.currentBid(), 0.01);
|
||||
assertEquals("USD", lot2.currency());
|
||||
|
||||
// Test "No bids" format
|
||||
ResultSet rs3 = createLotResultSet("No bids");
|
||||
Lot lot3 = ScraperDataAdapter.fromScraperLot(rs3);
|
||||
assertEquals(0.0, lot3.currentBid(), 0.01);
|
||||
|
||||
// Test plain number
|
||||
ResultSet rs4 = createLotResultSet("999.99");
|
||||
Lot lot4 = ScraperDataAdapter.fromScraperLot(rs4);
|
||||
assertEquals(999.99, lot4.currentBid(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle missing or null fields gracefully")
|
||||
void testHandleNullFields() throws SQLException {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("lot_id")).thenReturn("A1-12345-1");
|
||||
when(rs.getString("auction_id")).thenReturn("A7-99999");
|
||||
when(rs.getString("title")).thenReturn("Test Lot");
|
||||
when(rs.getString("description")).thenReturn(null);
|
||||
when(rs.getString("category")).thenReturn(null);
|
||||
when(rs.getString("current_bid")).thenReturn(null);
|
||||
when(rs.getString("closing_time")).thenReturn(null);
|
||||
when(rs.getString("url")).thenReturn("https://example.com/lot");
|
||||
|
||||
Lot result = ScraperDataAdapter.fromScraperLot(rs);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("", result.description());
|
||||
assertEquals("", result.category());
|
||||
assertEquals(0.0, result.currentBid());
|
||||
assertNull(result.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should parse various timestamp formats")
|
||||
void testTimestampParsing() throws SQLException {
|
||||
// ISO local date time
|
||||
ResultSet rs1 = mock(ResultSet.class);
|
||||
setupBasicLotMock(rs1);
|
||||
when(rs1.getString("closing_time")).thenReturn("2025-12-15T14:30:00");
|
||||
Lot lot1 = ScraperDataAdapter.fromScraperLot(rs1);
|
||||
assertNotNull(lot1.closingTime());
|
||||
assertEquals(LocalDateTime.of(2025, 12, 15, 14, 30, 0), lot1.closingTime());
|
||||
|
||||
// SQL timestamp format
|
||||
ResultSet rs2 = mock(ResultSet.class);
|
||||
setupBasicLotMock(rs2);
|
||||
when(rs2.getString("closing_time")).thenReturn("2025-12-15 14:30:00");
|
||||
Lot lot2 = ScraperDataAdapter.fromScraperLot(rs2);
|
||||
assertNotNull(lot2.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle invalid timestamp gracefully")
|
||||
void testInvalidTimestamp() throws SQLException {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
setupBasicLotMock(rs);
|
||||
when(rs.getString("closing_time")).thenReturn("invalid-date");
|
||||
|
||||
Lot result = ScraperDataAdapter.fromScraperLot(rs);
|
||||
assertNull(result.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract type prefix from auction ID")
|
||||
void testTypeExtraction() throws SQLException {
|
||||
ResultSet rs1 = mock(ResultSet.class);
|
||||
when(rs1.getString("auction_id")).thenReturn("A7-39813");
|
||||
when(rs1.getString("title")).thenReturn("Test");
|
||||
when(rs1.getString("location")).thenReturn("Test, NL");
|
||||
when(rs1.getString("url")).thenReturn("http://test.com");
|
||||
when(rs1.getInt("lots_count")).thenReturn(10);
|
||||
when(rs1.getString("first_lot_closing_time")).thenReturn(null);
|
||||
|
||||
AuctionInfo auction1 = ScraperDataAdapter.fromScraperAuction(rs1);
|
||||
assertEquals("A7", auction1.type());
|
||||
|
||||
ResultSet rs2 = mock(ResultSet.class);
|
||||
when(rs2.getString("auction_id")).thenReturn("B1-12345");
|
||||
when(rs2.getString("title")).thenReturn("Test");
|
||||
when(rs2.getString("location")).thenReturn("Test, NL");
|
||||
when(rs2.getString("url")).thenReturn("http://test.com");
|
||||
when(rs2.getInt("lots_count")).thenReturn(10);
|
||||
when(rs2.getString("first_lot_closing_time")).thenReturn(null);
|
||||
|
||||
AuctionInfo auction2 = ScraperDataAdapter.fromScraperAuction(rs2);
|
||||
assertEquals("B1", auction2.type());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle GBP currency symbol")
|
||||
void testGBPCurrency() throws SQLException {
|
||||
ResultSet rs = createLotResultSet("£75.00");
|
||||
Lot lot = ScraperDataAdapter.fromScraperLot(rs);
|
||||
assertEquals(75.00, lot.currentBid(), 0.01);
|
||||
assertEquals("GBP", lot.currency());
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private ResultSet createLotResultSet(String bidAmount) throws SQLException {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("lot_id")).thenReturn("A1-12345-1");
|
||||
when(rs.getString("auction_id")).thenReturn("A7-99999");
|
||||
when(rs.getString("title")).thenReturn("Test Lot");
|
||||
when(rs.getString("description")).thenReturn("Test description");
|
||||
when(rs.getString("category")).thenReturn("Test");
|
||||
when(rs.getString("current_bid")).thenReturn(bidAmount);
|
||||
when(rs.getString("closing_time")).thenReturn("2025-12-15T14:30:00");
|
||||
when(rs.getString("url")).thenReturn("https://example.com/lot");
|
||||
return rs;
|
||||
}
|
||||
|
||||
private void setupBasicLotMock(ResultSet rs) throws SQLException {
|
||||
when(rs.getString("lot_id")).thenReturn("A1-12345-1");
|
||||
when(rs.getString("auction_id")).thenReturn("A7-99999");
|
||||
when(rs.getString("title")).thenReturn("Test Lot");
|
||||
when(rs.getString("description")).thenReturn("Test");
|
||||
when(rs.getString("category")).thenReturn("Test");
|
||||
when(rs.getString("current_bid")).thenReturn("€100.00");
|
||||
when(rs.getString("url")).thenReturn("https://example.com/lot");
|
||||
}
|
||||
}
|
||||
381
src/test/java/com/auction/TroostwijkMonitorTest.java
Normal file
381
src/test/java/com/auction/TroostwijkMonitorTest.java
Normal file
@@ -0,0 +1,381 @@
|
||||
package com.auction;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Test cases for TroostwijkMonitor.
|
||||
* Tests monitoring orchestration, bid tracking, and notification triggers.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TroostwijkMonitorTest {
|
||||
|
||||
private String testDbPath;
|
||||
private TroostwijkMonitor monitor;
|
||||
|
||||
@BeforeAll
|
||||
void setUp() throws SQLException, IOException {
|
||||
testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db";
|
||||
|
||||
// Initialize with non-existent YOLO models (disabled mode)
|
||||
monitor = new TroostwijkMonitor(
|
||||
testDbPath,
|
||||
"desktop",
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void tearDown() throws Exception {
|
||||
Files.deleteIfExists(Paths.get(testDbPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize monitor successfully")
|
||||
void testMonitorInitialization() {
|
||||
assertNotNull(monitor);
|
||||
assertNotNull(monitor.db);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should print database stats without error")
|
||||
void testPrintDatabaseStats() {
|
||||
assertDoesNotThrow(() -> monitor.printDatabaseStats());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process pending images without error")
|
||||
void testProcessPendingImages() {
|
||||
assertDoesNotThrow(() -> monitor.processPendingImages());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty database gracefully")
|
||||
void testEmptyDatabaseHandling() throws SQLException {
|
||||
var auctions = monitor.db.getAllAuctions();
|
||||
var lots = monitor.db.getAllLots();
|
||||
|
||||
assertNotNull(auctions);
|
||||
assertNotNull(lots);
|
||||
assertTrue(auctions.isEmpty() || auctions.size() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should track lots in database")
|
||||
void testLotTracking() throws SQLException {
|
||||
// Insert test lot
|
||||
var lot = new Lot(
|
||||
11111, 22222,
|
||||
"Test Forklift",
|
||||
"Electric forklift in good condition",
|
||||
"Toyota",
|
||||
"Electric",
|
||||
2020,
|
||||
"Machinery",
|
||||
1500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/22222",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(lot);
|
||||
|
||||
var lots = monitor.db.getAllLots();
|
||||
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 22222));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should monitor lots closing soon")
|
||||
void testClosingSoonMonitoring() throws SQLException {
|
||||
// Insert lot closing in 4 minutes
|
||||
var closingSoon = new Lot(
|
||||
33333, 44444,
|
||||
"Closing Soon Item",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/44444",
|
||||
LocalDateTime.now().plusMinutes(4),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(closingSoon);
|
||||
|
||||
var lots = monitor.db.getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 44444)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.minutesUntilClose() < 30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should identify lots with time remaining")
|
||||
void testTimeRemainingCalculation() throws SQLException {
|
||||
var futureLot = new Lot(
|
||||
55555, 66666,
|
||||
"Future Lot",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
200.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/66666",
|
||||
LocalDateTime.now().plusHours(2),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(futureLot);
|
||||
|
||||
var lots = monitor.db.getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 66666)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.minutesUntilClose() > 60);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle lots without closing time")
|
||||
void testLotsWithoutClosingTime() throws SQLException {
|
||||
var noClosing = new Lot(
|
||||
77777, 88888,
|
||||
"No Closing Time",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
150.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/88888",
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(noClosing);
|
||||
|
||||
var lots = monitor.db.getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 88888)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertNull(found.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should track notification status")
|
||||
void testNotificationStatusTracking() throws SQLException {
|
||||
var lot = new Lot(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/11110",
|
||||
LocalDateTime.now().plusMinutes(3),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(lot);
|
||||
|
||||
// Update notification flag
|
||||
var notified = new Lot(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/11110",
|
||||
LocalDateTime.now().plusMinutes(3),
|
||||
true
|
||||
);
|
||||
|
||||
monitor.db.updateLotNotificationFlags(notified);
|
||||
|
||||
var lots = monitor.db.getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 11110)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update bid amounts")
|
||||
void testBidAmountUpdates() throws SQLException {
|
||||
var lot = new Lot(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/13131",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(lot);
|
||||
|
||||
// Simulate bid increase
|
||||
var higherBid = new Lot(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
250.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/13131",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.updateLotCurrentBid(higherBid);
|
||||
|
||||
var lots = monitor.db.getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 13131)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertEquals(250.00, found.currentBid(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple concurrent lot updates")
|
||||
void testConcurrentLotUpdates() throws InterruptedException {
|
||||
Thread t1 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
monitor.db.upsertLot(new Lot(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Thread 1 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
Thread t2 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 5; i < 10; i++) {
|
||||
monitor.db.upsertLot(new Lot(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Thread 2 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
t1.start();
|
||||
t2.start();
|
||||
t1.join();
|
||||
t2.join();
|
||||
|
||||
var lots = monitor.db.getActiveLots();
|
||||
long count = lots.stream()
|
||||
.filter(l -> l.lotId() >= 30000 && l.lotId() < 30010)
|
||||
.count();
|
||||
|
||||
assertTrue(count >= 10);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should schedule monitoring without error")
|
||||
void testScheduleMonitoring() {
|
||||
// This just tests that scheduling doesn't throw
|
||||
// Actual monitoring would run in background
|
||||
assertDoesNotThrow(() -> {
|
||||
// Don't actually start monitoring in test
|
||||
// Just verify monitor is ready
|
||||
assertNotNull(monitor);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle database with auctions and lots")
|
||||
void testDatabaseWithData() throws SQLException {
|
||||
// Insert auction
|
||||
var auction = new AuctionInfo(
|
||||
40000,
|
||||
"Test Auction",
|
||||
"Amsterdam, NL",
|
||||
"Amsterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/40000",
|
||||
"A7",
|
||||
10,
|
||||
LocalDateTime.now().plusDays(2)
|
||||
);
|
||||
|
||||
monitor.db.upsertAuction(auction);
|
||||
|
||||
// Insert related lot
|
||||
var lot = new Lot(
|
||||
40000, 50000,
|
||||
"Test Lot",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/50000",
|
||||
LocalDateTime.now().plusDays(2),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.db.upsertLot(lot);
|
||||
|
||||
// Verify
|
||||
var auctions = monitor.db.getAllAuctions();
|
||||
var lots = monitor.db.getAllLots();
|
||||
|
||||
assertTrue(auctions.stream().anyMatch(a -> a.auctionId() == 40000));
|
||||
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 50000));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user