This commit is contained in:
Tour
2025-12-03 17:17:49 +01:00
parent febd08821a
commit 8fff75dcf2
22 changed files with 4666 additions and 40 deletions

View File

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

View File

@@ -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.

View File

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

View 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();
}
}
}

View 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=/

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

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

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

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

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

View 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");
}
}

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