Initial clean commit

This commit is contained in:
Tour
2025-12-08 09:35:13 +01:00
commit 19a538d27a
79 changed files with 15794 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
package auctiora;
import java.time.LocalDateTime;
/**
* Represents auction metadata (veiling informatie)
* Data typically populated by the external scraper process
*/
public record AuctionInfo(
long auctionId, // Unique auction ID (from URL)
String title, // Auction title
String location, // Location (e.g., "Amsterdam, NL")
String city, // City name
String country, // Country code (e.g., "NL")
String url, // Full auction URL
String typePrefix, // Auction type (A1 or A7)
int lotCount, // Number of lots/kavels
LocalDateTime firstLotClosingTime // Closing time if available
) { }

View File

@@ -0,0 +1,82 @@
package auctiora;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Liveness;
import org.eclipse.microprofile.health.Readiness;
import org.eclipse.microprofile.health.Startup;
import java.nio.file.Files;
import java.nio.file.Paths;
@ApplicationScoped
public class AuctionMonitorHealthCheck {
@Liveness
public static class LivenessCheck
implements HealthCheck {
@Override public HealthCheckResponse call() {
return HealthCheckResponse.up("Auction Monitor is alive");
}
}
@Readiness
@ApplicationScoped
public static class ReadinessCheck
implements HealthCheck {
@Inject DatabaseService db;
@Override
public HealthCheckResponse call() {
try {
var auctions = db.getAllAuctions();
var dbPath = Paths.get("C:\\mnt\\okcomputer\\output\\cache.db");
if (!Files.exists(dbPath.getParent())) {
return HealthCheckResponse.down("Database directory does not exist");
}
return HealthCheckResponse.named("database")
.up()
.withData("auctions", auctions.size())
.build();
} catch (Exception e) {
return HealthCheckResponse.named("database")
.down()
.withData("error", e.getMessage())
.build();
}
}
}
@Startup
@ApplicationScoped
public static class StartupCheck
implements HealthCheck {
@Inject DatabaseService db;
@Override
public HealthCheckResponse call() {
try {
// Verify database schema
db.ensureSchema();
return HealthCheckResponse.named("startup")
.up()
.withData("message", "Database schema initialized")
.build();
} catch (Exception e) {
return HealthCheckResponse.named("startup")
.down()
.withData("error", e.getMessage())
.build();
}
}
}
}

View File

@@ -0,0 +1,61 @@
package auctiora;
import io.quarkus.runtime.Startup;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
import nu.pattern.OpenCV;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.opencv.core.Core;
import java.io.IOException;
import java.sql.SQLException;
/**
* CDI Producer for auction monitor services.
* Creates and configures singleton instances of core services.
*/
@Startup
@ApplicationScoped
public class AuctionMonitorProducer {
private static final Logger LOG = Logger.getLogger(AuctionMonitorProducer.class);
@PostConstruct void init() {
try {
OpenCV.loadLocally();
LOG.info("✓ OpenCV loaded successfully");
} catch (Exception e) {
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());
}
}
@Produces @Singleton public DatabaseService produceDatabaseService(
@ConfigProperty(name = "auction.database.path") String dbPath) throws SQLException {
var db = new DatabaseService(dbPath);
db.ensureSchema();
return db;
}
@Produces @Singleton public NotificationService produceNotificationService(
@ConfigProperty(name = "auction.notification.config") String config) {
return new NotificationService(config);
}
@Produces @Singleton public ObjectDetectionService produceObjectDetectionService(
@ConfigProperty(name = "auction.yolo.config") String cfgPath,
@ConfigProperty(name = "auction.yolo.weights") String weightsPath,
@ConfigProperty(name = "auction.yolo.classes") String classesPath) throws IOException {
return new ObjectDetectionService(cfgPath, weightsPath, classesPath);
}
@Produces @Singleton public ImageProcessingService produceImageProcessingService(
DatabaseService db,
ObjectDetectionService detector) {
return new ImageProcessingService(db, detector);
}
}

View File

@@ -0,0 +1,925 @@
package auctiora;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.stream.Collectors;
/**
* REST API for Auction Monitor control and status.
* Provides endpoints for:
* - Status checking
* - Manual workflow triggers
* - Statistics
*/
@Path("/api/monitor")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuctionMonitorResource {
private static final Logger LOG = Logger.getLogger(AuctionMonitorResource.class);
@Inject DatabaseService db;
@Inject QuarkusWorkflowScheduler scheduler;
@Inject NotificationService notifier;
@Inject RateLimitedHttpClient httpClient;
@Inject LotEnrichmentService enrichmentService;
/**
* GET /api/monitor/status
* Returns current monitoring status
*/
@GET
@Path("/status")
public Response getStatus() {
try {
Map<String, Object> status = new HashMap<>();
status.put("running", true);
status.put("auctions", db.getAllAuctions().size());
status.put("lots", db.getAllLots().size());
status.put("images", db.getImageCount());
// Count closing soon (within 30 minutes, excluding already-closed)
var closingSoon = 0;
for (var lot : db.getAllLots()) {
if (lot.closingTime() != null) {
long minutes = lot.minutesUntilClose();
if (minutes > 0 && minutes < 30) {
closingSoon++;
}
}
}
status.put("closingSoon", closingSoon);
return Response.ok(status).build();
} catch (Exception e) {
LOG.error("Failed to get status", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/statistics
* Returns detailed statistics
*/
@GET
@Path("/statistics")
public Response getStatistics() {
try {
Map<String, Object> stats = new HashMap<>();
var auctions = db.getAllAuctions();
var lots = db.getAllLots();
stats.put("totalAuctions", auctions.size());
stats.put("totalLots", lots.size());
stats.put("totalImages", db.getImageCount());
// Lot statistics
var activeLots = 0;
var lotsWithBids = 0;
double totalBids = 0;
var hotLots = 0;
var sleeperLots = 0;
var bargainLots = 0;
var lotsClosing1h = 0;
var lotsClosing6h = 0;
double totalBidVelocity = 0;
int velocityCount = 0;
for (var lot : lots) {
long minutesLeft = lot.closingTime() != null ? lot.minutesUntilClose() : Long.MAX_VALUE;
if (lot.closingTime() != null && minutesLeft > 0) {
activeLots++;
// Time-based counts
if (minutesLeft < 60) lotsClosing1h++;
if (minutesLeft < 360) lotsClosing6h++;
}
if (lot.currentBid() > 0) {
lotsWithBids++;
totalBids += lot.currentBid();
}
// Intelligence metrics (require GraphQL enrichment)
if (lot.followersCount() != null && lot.followersCount() > 20) {
hotLots++;
}
if (lot.isSleeperLot()) {
sleeperLots++;
}
if (lot.isBelowEstimate()) {
bargainLots++;
}
// Bid velocity
if (lot.bidVelocity() != null && lot.bidVelocity() > 0) {
totalBidVelocity += lot.bidVelocity();
velocityCount++;
}
}
// Calculate bids per hour (average velocity across all lots with velocity data)
double bidsPerHour = velocityCount > 0 ? totalBidVelocity / velocityCount : 0;
stats.put("activeLots", activeLots);
stats.put("lotsWithBids", lotsWithBids);
stats.put("totalBidValue", String.format("€%.2f", totalBids));
stats.put("averageBid", lotsWithBids > 0 ? String.format("€%.2f", totalBids / lotsWithBids) : "€0.00");
// Bidding intelligence
stats.put("bidsPerHour", String.format("%.1f", bidsPerHour));
stats.put("hotLots", hotLots);
stats.put("sleeperLots", sleeperLots);
stats.put("bargainLots", bargainLots);
stats.put("lotsClosing1h", lotsClosing1h);
stats.put("lotsClosing6h", lotsClosing6h);
// Conversion rate
double conversionRate = activeLots > 0 ? (lotsWithBids * 100.0 / activeLots) : 0;
stats.put("conversionRate", String.format("%.1f%%", conversionRate));
return Response.ok(stats).build();
} catch (Exception e) {
LOG.error("Failed to get statistics", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/closing-soon
* Returns lots closing within the next specified hours (default: 24 hours)
*/
@GET
@Path("/closing-soon")
public Response getClosingSoon(@QueryParam("hours") @DefaultValue("24") int hours) {
try {
var lots = db.getAllLots();
var closingSoon = lots.stream()
.filter(lot -> lot.closingTime() != null)
.filter(lot -> lot.minutesUntilClose() > 0 && lot.minutesUntilClose() <= hours * 60)
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
.limit(100)
.toList();
return Response.ok(closingSoon).build();
} catch (Exception e) {
LOG.error("Failed to get closing soon lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots/{lotId}/bid-history
* Returns bid history for a specific lot
*/
@GET
@Path("/lots/{lotId}/bid-history")
public Response getBidHistory(@PathParam("lotId") String lotId) {
try {
var history = db.getBidHistory(lotId);
return Response.ok(history).build();
} catch (Exception e) {
LOG.error("Failed to get bid history for lot {}", lotId, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/trigger/scraper-import
* Manually trigger scraper import workflow
*/
@POST
@Path("/trigger/scraper-import")
public Response triggerScraperImport() {
try {
scheduler.importScraperData();
return Response.ok(Map.of("message", "Scraper import triggered successfully")).build();
} catch (Exception e) {
LOG.error("Failed to trigger scraper import", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/trigger/image-processing
* Manually trigger image processing workflow
*/
@POST
@Path("/trigger/image-processing")
public Response triggerImageProcessing() {
try {
scheduler.processImages();
return Response.ok(Map.of("message", "Image processing triggered successfully")).build();
} catch (Exception e) {
LOG.error("Failed to trigger image processing", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/trigger/bid-monitoring
* Manually trigger bid monitoring workflow
*/
@POST
@Path("/trigger/bid-monitoring")
public Response triggerBidMonitoring() {
try {
scheduler.monitorBids();
return Response.ok(Map.of("message", "Bid monitoring triggered successfully")).build();
} catch (Exception e) {
LOG.error("Failed to trigger bid monitoring", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/trigger/closing-alerts
* Manually trigger closing alerts workflow
*/
@POST
@Path("/trigger/closing-alerts")
public Response triggerClosingAlerts() {
try {
scheduler.checkClosingTimes();
return Response.ok(Map.of("message", "Closing alerts triggered successfully")).build();
} catch (Exception e) {
LOG.error("Failed to trigger closing alerts", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/trigger/graphql-enrichment
* Manually trigger GraphQL enrichment for all lots or lots closing soon
*/
@POST
@Path("/trigger/graphql-enrichment")
public Response triggerGraphQLEnrichment(@QueryParam("hoursUntilClose") @DefaultValue("24") int hours) {
try {
int enriched;
if (hours > 0) {
enriched = enrichmentService.enrichClosingSoonLots(hours);
return Response.ok(Map.of(
"message", "GraphQL enrichment triggered for lots closing within " + hours + " hours",
"enrichedCount", enriched
)).build();
} else {
enriched = enrichmentService.enrichAllActiveLots();
return Response.ok(Map.of(
"message", "GraphQL enrichment triggered for all lots",
"enrichedCount", enriched
)).build();
}
} catch (Exception e) {
LOG.error("Failed to trigger GraphQL enrichment", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/auctions
* Returns list of all auctions
*/
@GET
@Path("/auctions")
public Response getAuctions(@QueryParam("country") String country) {
try {
var auctions = country != null && !country.isEmpty()
? db.getAuctionsByCountry(country)
: db.getAllAuctions();
return Response.ok(auctions).build();
} catch (Exception e) {
LOG.error("Failed to get auctions", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots
* Returns list of active lots
*/
@GET
@Path("/lots")
public Response getActiveLots() {
try {
var lots = db.getActiveLots();
return Response.ok(lots).build();
} catch (Exception e) {
LOG.error("Failed to get lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots/closing-soon
* Returns lots closing within specified minutes (default 30)
*/
@GET
@Path("/lots/closing-soon")
public Response getLotsClosingSoon(@QueryParam("minutes") @DefaultValue("30") int minutes) {
try {
var allLots = db.getActiveLots();
var closingSoon = allLots.stream()
.filter(lot -> lot.closingTime() != null)
.filter(lot -> {
long minutesLeft = lot.minutesUntilClose();
return minutesLeft > 0 && minutesLeft < minutes;
})
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
.toList();
return Response.ok(closingSoon).build();
} catch (Exception e) {
LOG.error("Failed to get closing lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots/{lotId}/images
* Returns images for a specific lot
*/
@GET
@Path("/lots/{lotId}/images")
public Response getLotImages(@PathParam("lotId") int lotId) {
try {
var images = db.getImagesForLot(lotId);
return Response.ok(images).build();
} catch (Exception e) {
LOG.error("Failed to get lot images", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/test-notification
* Send a test notification
*/
@POST
@Path("/test-notification")
public Response sendTestNotification(Map<String, String> request) {
try {
var message = request.getOrDefault("message", "Test notification from Auction Monitor");
var title = request.getOrDefault("title", "Test Notification");
var priority = Integer.parseInt(request.getOrDefault("priority", "0"));
notifier.sendNotification(message, title, priority);
return Response.ok(Map.of("message", "Test notification sent successfully")).build();
} catch (Exception e) {
LOG.error("Failed to send test notification", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/rate-limit/stats
* Returns HTTP rate limiting statistics for all hosts
*/
@GET
@Path("/rate-limit/stats")
public Response getRateLimitStats() {
try {
var stats = httpClient.getAllStats();
Map<String, Object> response = new HashMap<>();
response.put("hosts", stats.size());
Map<String, Object> hostStats = new HashMap<>();
for (var entry : stats.entrySet()) {
var stat = entry.getValue();
hostStats.put(entry.getKey(), Map.of(
"totalRequests", stat.getTotalRequests(),
"successfulRequests", stat.getSuccessfulRequests(),
"failedRequests", stat.getFailedRequests(),
"rateLimitedRequests", stat.getRateLimitedRequests(),
"averageDurationMs", stat.getAverageDurationMs()
));
}
response.put("statistics", hostStats);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get rate limit stats", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/rate-limit/stats/{host}
* Returns HTTP rate limiting statistics for a specific host
*/
@GET
@Path("/rate-limit/stats/{host}")
public Response getRateLimitStatsForHost(@PathParam("host") String host) {
try {
var stat = httpClient.getStats(host);
if (stat == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "No statistics found for host: " + host))
.build();
}
Map<String, Object> response = Map.of(
"host", stat.getHost(),
"totalRequests", stat.getTotalRequests(),
"successfulRequests", stat.getSuccessfulRequests(),
"failedRequests", stat.getFailedRequests(),
"rateLimitedRequests", stat.getRateLimitedRequests(),
"averageDurationMs", stat.getAverageDurationMs()
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get rate limit stats for host", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/charts/country-distribution
* Returns dynamic country distribution for charts
*/
@GET
@Path("/charts/country-distribution")
public Response getCountryDistribution() {
try {
var auctions = db.getAllAuctions();
Map<String, Long> distribution = auctions.stream()
.filter(a -> a.country() != null && !a.country().isEmpty())
.collect(Collectors.groupingBy(
AuctionInfo::country,
Collectors.counting()
));
return Response.ok(distribution).build();
} catch (Exception e) {
LOG.error("Failed to get country distribution", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/charts/category-distribution
* Returns dynamic category distribution with intelligence for charts
*/
@GET
@Path("/charts/category-distribution")
public Response getCategoryDistribution() {
try {
var lots = db.getAllLots();
// Category distribution
Map<String, Long> distribution = lots.stream()
.filter(l -> l.category() != null && !l.category().isEmpty())
.collect(Collectors.groupingBy(
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
Collectors.counting()
));
// Find top category by count
var topCategory = distribution.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("N/A");
// Calculate average bids per category
Map<String, Double> avgBidsByCategory = lots.stream()
.filter(l -> l.category() != null && !l.category().isEmpty() && l.currentBid() > 0)
.collect(Collectors.groupingBy(
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
Collectors.averagingDouble(Lot::currentBid)
));
double overallAvgBid = lots.stream()
.filter(l -> l.currentBid() > 0)
.mapToDouble(Lot::currentBid)
.average()
.orElse(0.0);
Map<String, Object> response = new HashMap<>();
response.put("distribution", distribution);
response.put("topCategory", topCategory);
response.put("categoryCount", distribution.size());
response.put("averageBidOverall", String.format("€%.2f", overallAvgBid));
response.put("avgBidsByCategory", avgBidsByCategory);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get category distribution", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/charts/bidding-trend
* Returns time series data for last N hours
*/
@GET
@Path("/charts/bidding-trend")
public Response getBiddingTrend(@QueryParam("hours") @DefaultValue("24") int hours) {
try {
var lots = db.getAllLots();
Map<Integer, TrendHour> trends = new HashMap<>();
// Initialize hours
LocalDateTime now = LocalDateTime.now();
for (int i = hours - 1; i >= 0; i--) {
LocalDateTime hour = now.minusHours(i);
int hourKey = hour.getHour();
trends.put(hourKey, new TrendHour(hourKey, 0, 0));
}
// Count lots and bids per hour (mock implementation - in real app, use timestamp data)
// This is a simplified version - you'd need actual timestamps in DB
for (var lot : lots) {
if (lot.closingTime() != null) {
int hour = lot.closingTime().getHour();
TrendHour trend = trends.getOrDefault(hour, new TrendHour(hour, 0, 0));
trend.lots++;
if (lot.currentBid() > 0) trend.bids++;
}
}
return Response.ok(trends.values()).build();
} catch (Exception e) {
LOG.error("Failed to get bidding trend", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/charts/insights
* Returns intelligent insights
*/
@GET
@Path("/charts/insights")
public Response getInsights() {
try {
var lots = db.getAllLots();
var auctions = db.getAllAuctions();
List<Map<String, String>> insights = new ArrayList<>();
// Calculate insights
long criticalCount = lots.stream().filter(l -> l.minutesUntilClose() < 30).count();
if (criticalCount > 10) {
insights.add(Map.of(
"icon", "fa-exclamation-circle",
"title", criticalCount + " lots closing soon",
"description", "High urgency items require attention"
));
}
double bidRate = lots.stream().filter(l -> l.currentBid() > 0).count() * 100.0 / lots.size();
if (bidRate > 60) {
insights.add(Map.of(
"icon", "fa-chart-line",
"title", String.format("%.1f%% bid rate", bidRate),
"description", "Strong market engagement detected"
));
}
long imageCoverage = db.getImageCount() * 100 / Math.max(lots.size(), 1);
if (imageCoverage < 80) {
insights.add(Map.of(
"icon", "fa-images",
"title", imageCoverage + "% image coverage",
"description", "Consider processing more images"
));
}
// Add geographic insight (filter out null countries)
String topCountry = auctions.stream()
.filter(a -> a.country() != null)
.collect(Collectors.groupingBy(AuctionInfo::country, Collectors.counting()))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("N/A");
if (!"N/A".equals(topCountry)) {
insights.add(Map.of(
"icon", "fa-globe",
"title", topCountry + " leading",
"description", "Top performing country"
));
}
// Add sleeper lots insight
long sleeperCount = lots.stream().filter(Lot::isSleeperLot).count();
if (sleeperCount > 0) {
insights.add(Map.of(
"icon", "fa-eye",
"title", sleeperCount + " sleeper lots",
"description", "High interest, low bids - opportunity?"
));
}
// Add bargain insight
long bargainCount = lots.stream().filter(Lot::isBelowEstimate).count();
if (bargainCount > 5) {
insights.add(Map.of(
"icon", "fa-tag",
"title", bargainCount + " bargains",
"description", "Priced below auction house estimates"
));
}
// Add watch/followers insight
long highWatchCount = lots.stream()
.filter(l -> l.followersCount() != null && l.followersCount() > 20)
.count();
if (highWatchCount > 0) {
insights.add(Map.of(
"icon", "fa-fire",
"title", highWatchCount + " hot lots",
"description", "High follower count, strong competition"
));
}
return Response.ok(insights).build();
} catch (Exception e) {
LOG.error("Failed to get insights", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/sleepers
* Returns "sleeper" lots (high watch count, low bids)
*/
@GET
@Path("/intelligence/sleepers")
public Response getSleeperLots(@QueryParam("minFollowers") @DefaultValue("10") int minFollowers) {
try {
var allLots = db.getAllLots();
var sleepers = allLots.stream()
.filter(Lot::isSleeperLot)
.toList();
Map<String, Object> response = Map.of(
"count", sleepers.size(),
"lots", sleepers
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get sleeper lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/bargains
* Returns lots priced below auction house estimates
*/
@GET
@Path("/intelligence/bargains")
public Response getBargains() {
try {
var allLots = db.getAllLots();
var bargains = allLots.stream()
.filter(Lot::isBelowEstimate)
.sorted((a, b) -> {
Double ratioA = a.getPriceVsEstimateRatio();
Double ratioB = b.getPriceVsEstimateRatio();
if (ratioA == null) return 1;
if (ratioB == null) return -1;
return ratioA.compareTo(ratioB);
})
.toList();
Map<String, Object> response = Map.of(
"count", bargains.size(),
"lots", bargains
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get bargains", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/popular
* Returns lots by popularity level
*/
@GET
@Path("/intelligence/popular")
public Response getPopularLots(@QueryParam("level") @DefaultValue("HIGH") String level) {
try {
var allLots = db.getAllLots();
var popular = allLots.stream()
.filter(lot -> level.equalsIgnoreCase(lot.getPopularityLevel()))
.sorted((a, b) -> {
Integer followersA = a.followersCount() != null ? a.followersCount() : 0;
Integer followersB = b.followersCount() != null ? b.followersCount() : 0;
return followersB.compareTo(followersA);
})
.toList();
Map<String, Object> response = Map.of(
"count", popular.size(),
"level", level,
"lots", popular
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get popular lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/price-analysis
* Returns price vs estimate analysis
*/
@GET
@Path("/intelligence/price-analysis")
public Response getPriceAnalysis() {
try {
var allLots = db.getAllLots();
long belowEstimate = allLots.stream().filter(Lot::isBelowEstimate).count();
long aboveEstimate = allLots.stream().filter(Lot::isAboveEstimate).count();
long withEstimates = allLots.stream()
.filter(lot -> lot.estimatedMin() != null && lot.estimatedMax() != null)
.count();
double avgPriceVsEstimate = allLots.stream()
.map(Lot::getPriceVsEstimateRatio)
.filter(ratio -> ratio != null)
.mapToDouble(Double::doubleValue)
.average()
.orElse(0.0);
Map<String, Object> response = Map.of(
"totalLotsWithEstimates", withEstimates,
"belowEstimate", belowEstimate,
"aboveEstimate", aboveEstimate,
"averagePriceVsEstimatePercent", Math.round(avgPriceVsEstimate),
"bargainOpportunities", belowEstimate
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get price analysis", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots/{lotId}/intelligence
* Returns detailed intelligence for a specific lot
*/
@GET
@Path("/lots/{lotId}/intelligence")
public Response getLotIntelligence(@PathParam("lotId") long lotId) {
try {
var lot = db.getAllLots().stream()
.filter(l -> l.lotId() == lotId)
.findFirst()
.orElse(null);
if (lot == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Lot not found"))
.build();
}
Map<String, Object> intelligence = new HashMap<>();
intelligence.put("lotId", lot.lotId());
intelligence.put("followersCount", lot.followersCount());
intelligence.put("popularityLevel", lot.getPopularityLevel());
intelligence.put("estimatedMidpoint", lot.getEstimatedMidpoint());
intelligence.put("priceVsEstimatePercent", lot.getPriceVsEstimateRatio());
intelligence.put("isBargain", lot.isBelowEstimate());
intelligence.put("isOvervalued", lot.isAboveEstimate());
intelligence.put("isSleeperLot", lot.isSleeperLot());
intelligence.put("nextBidAmount", lot.calculateNextBid());
intelligence.put("totalCostWithFees", lot.calculateTotalCost());
intelligence.put("viewCount", lot.viewCount());
intelligence.put("bidVelocity", lot.bidVelocity());
intelligence.put("condition", lot.condition());
intelligence.put("vat", lot.vat());
intelligence.put("buyerPremium", lot.buyerPremiumPercentage());
return Response.ok(intelligence).build();
} catch (Exception e) {
LOG.error("Failed to get lot intelligence", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/charts/watch-distribution
* Returns follower/watch count distribution
*/
@GET
@Path("/charts/watch-distribution")
public Response getWatchDistribution() {
try {
var lots = db.getAllLots();
Map<String, Long> distribution = new HashMap<>();
distribution.put("0 watchers", lots.stream().filter(l -> l.followersCount() == null || l.followersCount() == 0).count());
distribution.put("1-5 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 1 && l.followersCount() <= 5).count());
distribution.put("6-20 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 6 && l.followersCount() <= 20).count());
distribution.put("21-50 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 21 && l.followersCount() <= 50).count());
distribution.put("50+ watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() > 50).count());
return Response.ok(distribution).build();
} catch (Exception e) {
LOG.error("Failed to get watch distribution", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
// Helper class for trend data
public static class TrendHour {
public int hour;
public int lots;
public int bids;
public TrendHour(int hour, int lots, int bids) {
this.hour = hour;
this.lots = lots;
this.bids = bids;
}
}
}

View File

@@ -0,0 +1,16 @@
package auctiora;
import java.time.LocalDateTime;
/**
* Represents a bid in the bid history
*/
public record BidHistory(
int id,
String lotId,
double bidAmount,
LocalDateTime bidTime,
boolean isAutobid,
String bidderId,
Integer bidderNumber
) {}

View File

@@ -0,0 +1,218 @@
package auctiora;
import auctiora.db.*;
import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.core.Jdbi;
import java.util.List;
/**
* Refactored database service using repository pattern and JDBI3.
* Delegates operations to specialized repositories for better separation of concerns.
*
* @deprecated Legacy methods maintained for backward compatibility.
* New code should use repositories directly via dependency injection.
*/
@Slf4j
public class DatabaseService {
private final Jdbi jdbi;
private final LotRepository lotRepository;
private final AuctionRepository auctionRepository;
private final ImageRepository imageRepository;
/**
* Constructor for programmatic instantiation (tests, CLI tools).
*/
public DatabaseService(String dbPath) {
String url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
this.jdbi = Jdbi.create(url);
// Initialize schema
DatabaseSchema.ensureSchema(jdbi);
// Create repositories
this.lotRepository = new LotRepository(jdbi);
this.auctionRepository = new AuctionRepository(jdbi);
this.imageRepository = new ImageRepository(jdbi);
}
/**
* Constructor with JDBI instance (for dependency injection).
*/
public DatabaseService(Jdbi jdbi) {
this.jdbi = jdbi;
DatabaseSchema.ensureSchema(jdbi);
this.lotRepository = new LotRepository(jdbi);
this.auctionRepository = new AuctionRepository(jdbi);
this.imageRepository = new ImageRepository(jdbi);
}
// ==================== LEGACY COMPATIBILITY METHODS ====================
// These methods delegate to repositories for backward compatibility
void ensureSchema() {
DatabaseSchema.ensureSchema(jdbi);
}
synchronized void upsertAuction(AuctionInfo auction) {
auctionRepository.upsert(auction);
}
synchronized List<AuctionInfo> getAllAuctions() {
return auctionRepository.getAll();
}
synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) {
return auctionRepository.getByCountry(countryCode);
}
synchronized void upsertLot(Lot lot) {
lotRepository.upsert(lot);
}
synchronized void upsertLotWithIntelligence(Lot lot) {
lotRepository.upsertWithIntelligence(lot);
}
synchronized void updateLotCurrentBid(Lot lot) {
lotRepository.updateCurrentBid(lot);
}
synchronized void updateLotNotificationFlags(Lot lot) {
lotRepository.updateNotificationFlags(lot);
}
synchronized List<Lot> getActiveLots() {
return lotRepository.getActiveLots();
}
synchronized List<Lot> getAllLots() {
return lotRepository.getAllLots();
}
synchronized List<BidHistory> getBidHistory(String lotId) {
return lotRepository.getBidHistory(lotId);
}
synchronized void insertBidHistory(List<BidHistory> bidHistory) {
lotRepository.insertBidHistory(bidHistory);
}
synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) {
imageRepository.insert(lotId, url, filePath, labels);
}
synchronized void updateImageLabels(int imageId, List<String> labels) {
imageRepository.updateLabels(imageId, labels);
}
synchronized List<String> getImageLabels(int imageId) {
return imageRepository.getLabels(imageId);
}
synchronized List<ImageRecord> getImagesForLot(long lotId) {
return imageRepository.getImagesForLot(lotId)
.stream()
.map(img -> new ImageRecord(img.id(), img.lotId(), img.url(), img.filePath(), img.labels()))
.toList();
}
synchronized List<ImageDetectionRecord> getImagesNeedingDetection() {
return imageRepository.getImagesNeedingDetection()
.stream()
.map(img -> new ImageDetectionRecord(img.id(), img.lotId(), img.filePath()))
.toList();
}
synchronized int getImageCount() {
return imageRepository.getImageCount();
}
synchronized List<AuctionInfo> importAuctionsFromScraper() {
return jdbi.withHandle(handle -> {
var sql = """
SELECT
l.auction_id,
MIN(l.title) as title,
MIN(l.location) as location,
MIN(l.url) as url,
COUNT(*) as lots_count,
MIN(l.closing_time) as first_lot_closing_time,
MIN(l.scraped_at) as scraped_at
FROM lots l
WHERE l.auction_id IS NOT NULL
GROUP BY l.auction_id
""";
return handle.createQuery(sql)
.map((rs, ctx) -> {
try {
var auction = ScraperDataAdapter.fromScraperAuction(rs);
if (auction.auctionId() != 0L) {
auctionRepository.upsert(auction);
return auction;
}
} catch (Exception e) {
log.warn("Failed to import auction: {}", e.getMessage());
}
return null;
})
.list()
.stream()
.filter(a -> a != null)
.toList();
});
}
synchronized List<Lot> importLotsFromScraper() {
return jdbi.withHandle(handle -> {
var sql = "SELECT * FROM lots";
return handle.createQuery(sql)
.map((rs, ctx) -> {
try {
var lot = ScraperDataAdapter.fromScraperLot(rs);
if (lot.lotId() != 0L && lot.saleId() != 0L) {
lotRepository.upsert(lot);
return lot;
}
} catch (Exception e) {
log.warn("Failed to import lot: {}", e.getMessage());
}
return null;
})
.list()
.stream()
.filter(l -> l != null)
.toList();
});
}
// ==================== DIRECT REPOSITORY ACCESS ====================
// Expose repositories for modern usage patterns
public LotRepository lots() {
return lotRepository;
}
public AuctionRepository auctions() {
return auctionRepository;
}
public ImageRepository images() {
return imageRepository;
}
public Jdbi getJdbi() {
return jdbi;
}
// ==================== LEGACY RECORDS ====================
// Keep records for backward compatibility with existing code
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) {}
public record ImageDetectionRecord(int id, long lotId, String filePath) {}
}

View File

@@ -0,0 +1,55 @@
package auctiora;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public record ImageProcessingService(DatabaseService db, ObjectDetectionService detector) {
boolean processImage(int id, String path, long lot) {
try {
path = path.replace('\\', '/');
var f = new java.io.File(path);
if (!f.exists() || !f.canRead()) {
log.warn("Image not accessible: {}", path);
return false;
}
if (f.length() > 50L * 1024 * 1024) {
log.warn("Image too large: {}", path);
return false;
}
var labels = detector.detectObjects(path);
db.updateImageLabels(id, labels);
if (!labels.isEmpty())
log.info("Lot {}: {}", lot, String.join(", ", labels));
return true;
} catch (Exception e) {
log.warn("Process fail {}: {}", id, e.getMessage());
return false;
}
}
void processPendingImages() {
try {
var images = db.getImagesNeedingDetection();
log.info("Pending {}", images.size());
int processed = 0, detected = 0;
for (var i : images) {
if (processImage(i.id(), i.filePath(), i.lotId())) {
processed++;
var lbl = db.getImageLabels(i.id());
if (lbl != null && !lbl.isEmpty()) detected++;
}
}
log.info("Processed {}, detected {}", processed, detected);
} catch (Exception e) {
log.warn("Batch fail: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,153 @@
package auctiora;
import lombok.With;
import java.time.Duration;
import java.time.LocalDateTime;
/// Represents a lot (kavel) in an auction.
/// Data typically populated by the external scraper process.
/// This project enriches the data with image analysis and monitoring.
@With
public record Lot(
long saleId,
long lotId,
String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries
String title,
String description,
String manufacturer,
String type,
int year,
String category,
double currentBid,
String currency,
String url,
LocalDateTime closingTime,
boolean closingNotified,
// HIGH PRIORITY FIELDS from GraphQL API
Integer followersCount, // Watch count - direct competition indicator
Double estimatedMin, // Auction house min estimate (cents)
Double estimatedMax, // Auction house max estimate (cents)
Long nextBidStepInCents, // Exact bid increment from API
String condition, // Direct condition field
String categoryPath, // Structured category (e.g., "Vehicles > Cars > Classic")
String cityLocation, // Structured location
String countryCode, // ISO country code
// MEDIUM PRIORITY FIELDS
String biddingStatus, // More detailed than minimumBidAmountMet
String appearance, // Visual condition notes
String packaging, // Packaging details
Long quantity, // Lot quantity (bulk lots)
Double vat, // VAT percentage
Double buyerPremiumPercentage, // Buyer premium
String remarks, // Viewing/pickup notes
// BID INTELLIGENCE FIELDS
Double startingBid, // Starting/opening bid
Double reservePrice, // Reserve price (if disclosed)
Boolean reserveMet, // Reserve met status
Double bidIncrement, // Calculated bid increment
Integer viewCount, // Number of views
LocalDateTime firstBidTime, // First bid timestamp
LocalDateTime lastBidTime, // Last bid timestamp
Double bidVelocity, // Bids per hour
Double condition_score,
//Integer manufacturing_year,
Integer provenance_docs
) {
public Integer provenanceDocs() { return provenance_docs; }
/// manufacturing_year
public Integer manufacturingYear() { return year; }
public Double conditionScore() { return condition_score; }
public long minutesUntilClose() {
if (closingTime == null) return Long.MAX_VALUE;
return Duration.between(LocalDateTime.now(), closingTime).toMinutes();
}
// Intelligence Methods
/// Calculate total cost including VAT and buyer premium
public double calculateTotalCost() {
double base = currentBid > 0 ? currentBid : 0;
if (vat != null && vat > 0) {
base += (base * vat / 100.0);
}
if (buyerPremiumPercentage != null && buyerPremiumPercentage > 0) {
base += (base * buyerPremiumPercentage / 100.0);
}
return base;
}
/// Calculate next bid amount using API-provided increment
public double calculateNextBid() {
if (nextBidStepInCents != null && nextBidStepInCents > 0) {
return currentBid + (nextBidStepInCents / 100.0);
} else if (bidIncrement != null && bidIncrement > 0) {
return currentBid + bidIncrement;
}
// Fallback: 5% increment
return currentBid * 1.05;
}
/// Check if current bid is below estimate (potential bargain)
public boolean isBelowEstimate() {
if (estimatedMin == null || estimatedMin == 0) return false;
return currentBid < (estimatedMin / 100.0);
}
/// Check if current bid exceeds estimate (overvalued)
public boolean isAboveEstimate() {
if (estimatedMax == null || estimatedMax == 0) return false;
return currentBid > (estimatedMax / 100.0);
}
/// Calculate interest-to-bid conversion rate
public double getInterestToBidRatio() {
if (followersCount == null || followersCount == 0) return 0.0;
return currentBid > 0 ? 100.0 : 0.0;
}
/// Determine lot popularity level
public String getPopularityLevel() {
if (followersCount == null) return "UNKNOWN";
if (followersCount > 50) return "HIGH";
if (followersCount > 20) return "MEDIUM";
if (followersCount > 5) return "LOW";
return "MINIMAL";
}
/// Check if lot is a "sleeper" (high interest, low bids)
public boolean isSleeperLot() {
return followersCount != null && followersCount > 10 && currentBid < 100;
}
/// Calculate estimated value range midpoint
public Double getEstimatedMidpoint() {
if (estimatedMin == null || estimatedMax == null) return null;
return (estimatedMin + estimatedMax) / 200.0; // Convert from cents
}
/// Calculate price vs estimate ratio (for analytics)
public Double getPriceVsEstimateRatio() {
Double midpoint = getEstimatedMidpoint();
if (midpoint == null || midpoint == 0 || currentBid == 0) return null;
return (currentBid / midpoint) * 100.0;
}
/// Factory method for creating a basic Lot without intelligence fields (for tests and backward compatibility)
public static Lot basic(
long saleId, long lotId, String title, String description,
String manufacturer, String type, int year, String category,
double currentBid, String currency, String url,
LocalDateTime closingTime, boolean closingNotified) {
return new Lot(
saleId, lotId, null, title, description, manufacturer, type, year, category,
currentBid, currency, url, closingTime, closingNotified,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null
);
}
}

View File

@@ -0,0 +1,81 @@
package auctiora;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
/**
* Scheduled tasks for enriching lots with GraphQL intelligence data.
* Uses dynamic frequencies based on lot closing times:
* - Critical (< 1 hour): Every 5 minutes
* - Urgent (< 6 hours): Every 30 minutes
* - Normal (< 24 hours): Every 2 hours
* - All lots: Every 6 hours
*/
@Slf4j
@ApplicationScoped
public class LotEnrichmentScheduler {
@Inject LotEnrichmentService enrichmentService;
/**
* Enriches lots closing within 1 hour - HIGH PRIORITY
* Runs every 5 minutes
*/
@Scheduled(cron = "0 */5 * * * ?")
public void enrichCriticalLots() {
try {
log.debug("Enriching critical lots (closing < 1 hour)");
int enriched = enrichmentService.enrichClosingSoonLots(1);
if (enriched > 0) log.info("Enriched {} critical lots", enriched);
} catch (Exception e) {
log.error("Failed to enrich critical lots", e);
}
}
/**
* Enriches lots closing within 6 hours - MEDIUM PRIORITY
* Runs every 30 minutes
*/
@Scheduled(cron = "0 */30 * * * ?")
public void enrichUrgentLots() {
try {
log.debug("Enriching urgent lots (closing < 6 hours)");
int enriched = enrichmentService.enrichClosingSoonLots(6);
if (enriched > 0) log.info("Enriched {} urgent lots", enriched);
} catch (Exception e) {
log.error("Failed to enrich urgent lots", e);
}
}
/**
* Enriches lots closing within 24 hours - NORMAL PRIORITY
* Runs every 2 hours
*/
@Scheduled(cron = "0 0 */2 * * ?")
public void enrichDailyLots() {
try {
log.debug("Enriching daily lots (closing < 24 hours)");
int enriched = enrichmentService.enrichClosingSoonLots(24);
if (enriched > 0) log.info("Enriched {} daily lots", enriched);
} catch (Exception e) {
log.error("Failed to enrich daily lots", e);
}
}
/**
* Enriches all active lots - LOW PRIORITY
* Runs every 6 hours to keep all data fresh
*/
@Scheduled(cron = "0 0 */6 * * ?")
public void enrichAllLots() {
try {
log.info("Starting full enrichment of all lots");
int enriched = enrichmentService.enrichAllActiveLots();
log.info("Full enrichment complete: {} lots updated", enriched);
} catch (Exception e) {
log.error("Failed to enrich all lots", e);
}
}
}

View File

@@ -0,0 +1,201 @@
package auctiora;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Service for enriching lots with intelligence data from GraphQL API.
* Updates existing lot records with followers, estimates, velocity, etc.
*/
@Slf4j
@ApplicationScoped
public class LotEnrichmentService {
@Inject TroostwijkGraphQLClient graphQLClient;
@Inject DatabaseService db;
/**
* Enriches a single lot with GraphQL intelligence data
*/
public boolean enrichLot(Lot lot) {
if (lot.displayId() == null || lot.displayId().isBlank()) {
log.debug("Cannot enrich lot {} - missing displayId", lot.lotId());
return false;
}
try {
var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
if (intelligence == null) {
log.debug("No intelligence data for lot {}", lot.displayId());
return false;
}
// Merge intelligence with existing lot data
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
db.upsertLotWithIntelligence(enrichedLot);
log.debug("Enriched lot {} with GraphQL data", lot.lotId());
return true;
} catch (Exception e) {
log.warn("Failed to enrich lot {}: {}", lot.lotId(), e.getMessage());
return false;
}
}
/**
* Enriches multiple lots sequentially
* @param lots List of lots to enrich
* @return Number of successfully enriched lots
*/
public int enrichLotsBatch(List<Lot> lots) {
if (lots.isEmpty()) {
return 0;
}
log.info("Enriching {} lots via GraphQL", lots.size());
int enrichedCount = 0;
for (var lot : lots) {
if (lot.displayId() == null || lot.displayId().isBlank()) {
log.debug("Skipping lot {} - missing displayId", lot.lotId());
continue;
}
try {
var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
if (intelligence != null) {
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
db.upsertLotWithIntelligence(enrichedLot);
enrichedCount++;
} else {
log.debug("No intelligence data for lot {}", lot.displayId());
}
} catch (Exception e) {
log.warn("Failed to enrich lot {}: {}", lot.displayId(), e.getMessage());
}
// Small delay to respect rate limits (handled by RateLimitedHttpClient)
}
log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size());
return enrichedCount;
}
/**
* Enriches lots closing soon (within specified hours) with higher priority
*/
public int enrichClosingSoonLots(int hoursUntilClose) {
try {
var allLots = db.getAllLots();
var closingSoon = allLots.stream()
.filter(lot -> lot.closingTime() != null)
.filter(lot -> {
long minutes = lot.minutesUntilClose();
return minutes > 0 && minutes <= hoursUntilClose * 60;
})
.toList();
if (closingSoon.isEmpty()) {
log.debug("No lots closing within {} hours", hoursUntilClose);
return 0;
}
log.info("Enriching {} lots closing within {} hours", closingSoon.size(), hoursUntilClose);
return enrichLotsBatch(closingSoon);
} catch (Exception e) {
log.error("Failed to enrich closing soon lots: {}", e.getMessage());
return 0;
}
}
/**
* Enriches all active lots (can be slow for large datasets)
*/
public int enrichAllActiveLots() {
try {
var allLots = db.getAllLots();
log.info("Enriching all {} active lots", allLots.size());
// Process in batches to avoid overwhelming the API
int batchSize = 50;
int totalEnriched = 0;
for (int i = 0; i < allLots.size(); i += batchSize) {
int end = Math.min(i + batchSize, allLots.size());
List<Lot> batch = allLots.subList(i, end);
int enriched = enrichLotsBatch(batch);
totalEnriched += enriched;
// Small delay between batches to respect rate limits
if (end < allLots.size()) {
Thread.sleep(1000);
}
}
log.info("Finished enriching all lots. Total enriched: {}/{}", totalEnriched, allLots.size());
return totalEnriched;
} catch (Exception e) {
log.error("Failed to enrich all lots: {}", e.getMessage());
return 0;
}
}
/**
* Merges existing lot data with GraphQL intelligence
*/
private Lot mergeLotWithIntelligence(Lot lot, LotIntelligence intel) {
return new Lot(
lot.saleId(),
lot.lotId(),
lot.displayId(), // Preserve displayId
lot.title(),
lot.description(),
lot.manufacturer(),
lot.type(),
lot.year(),
lot.category(),
lot.currentBid(),
lot.currency(),
lot.url(),
lot.closingTime(),
lot.closingNotified(),
// HIGH PRIORITY FIELDS from GraphQL
intel.followersCount(),
intel.estimatedMin(),
intel.estimatedMax(),
intel.nextBidStepInCents(),
intel.condition(),
intel.categoryPath(),
intel.cityLocation(),
intel.countryCode(),
// MEDIUM PRIORITY FIELDS
intel.biddingStatus(),
intel.appearance(),
intel.packaging(),
intel.quantity(),
intel.vat(),
intel.buyerPremiumPercentage(),
intel.remarks(),
// BID INTELLIGENCE FIELDS
intel.startingBid(),
intel.reservePrice(),
intel.reserveMet(),
intel.bidIncrement(),
intel.viewCount(),
intel.firstBidTime(),
intel.lastBidTime(),
intel.bidVelocity(),
null, // condition_score (computed separately)
null // provenance_docs (computed separately)
);
}
}

View File

@@ -0,0 +1,33 @@
package auctiora;
import java.time.LocalDateTime;
/**
* Record holding enriched intelligence data fetched from GraphQL API
*/
public record LotIntelligence(
long lotId,
Integer followersCount,
Double estimatedMin,
Double estimatedMax,
Long nextBidStepInCents,
String condition,
String categoryPath,
String cityLocation,
String countryCode,
String biddingStatus,
String appearance,
String packaging,
Long quantity,
Double vat,
Double buyerPremiumPercentage,
String remarks,
Double startingBid,
Double reservePrice,
Boolean reserveMet,
Double bidIncrement,
Integer viewCount,
LocalDateTime firstBidTime,
LocalDateTime lastBidTime,
Double bidVelocity
) {}

View File

@@ -0,0 +1,170 @@
package auctiora;
import javax.mail.Authenticator;
import javax.mail.Message;
import javax.mail.PasswordAuthentication;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import lombok.extern.slf4j.Slf4j;
import java.awt.SystemTray;
import java.awt.Toolkit;
import java.awt.TrayIcon;
import java.util.Date;
import java.util.Properties;
@Slf4j
public record NotificationService(Config cfg) {
// Extra convenience constructor: raw string → Config
public NotificationService(String raw) {
this(Config.parse(raw));
}
public void sendNotification(String msg, String title, int prio) {
if (cfg.useDesktop()) sendDesktop(title, msg, prio);
if (cfg.useEmail()) sendEmail(title, msg, prio);
}
private void sendDesktop(String title, String msg, int prio) {
try {
if (!SystemTray.isSupported()) {
log.info("Desktop not supported: {}", title);
return;
}
var tray = SystemTray.getSystemTray();
var icon = new TrayIcon(
Toolkit.getDefaultToolkit().createImage(new byte[0]),
"notify"
);
icon.setImageAutoSize(true);
tray.add(icon);
var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO;
icon.displayMessage(title, msg, type);
// Remove tray icon asynchronously to avoid blocking the caller
int delayMs = Integer.getInteger("auctiora.desktop.delay.ms", 0);
if (delayMs <= 0) {
var t = new Thread(() -> {
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
try {
tray.remove(icon);
} catch (Exception ignored) {
}
}, "tray-remove");
t.setDaemon(true);
t.start();
} else {
try {
Thread.sleep(delayMs);
} catch (InterruptedException ignored) {
} finally {
try {
tray.remove(icon);
} catch (Exception ignored) {
}
}
}
log.info("Desktop notification: {}", title);
} catch (Exception e) {
log.warn("Desktop failed: {}", e.getMessage());
}
}
private void sendEmail(String title, String msg, int prio) {
try {
var props = new Properties();
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
props.put("mail.smtp.host", "smtp.gmail.com");
props.put("mail.smtp.port", "587");
props.put("mail.smtp.ssl.trust", "smtp.gmail.com");
props.put("mail.smtp.ssl.protocols", "TLSv1.2");
// Connection timeouts (configurable; short during tests, longer otherwise)
int smtpTimeoutMs = Integer.getInteger("auctiora.smtp.timeout.ms", isUnderTest() ? 200 : 10000);
String t = String.valueOf(smtpTimeoutMs);
props.put("mail.smtp.connectiontimeout", t);
props.put("mail.smtp.timeout", t);
props.put("mail.smtp.writetimeout", t);
var session = Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(cfg.smtpUsername(), cfg.smtpPassword());
}
});
var m = new MimeMessage(session);
m.setFrom(new InternetAddress(cfg.smtpUsername()));
m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(cfg.toEmail()));
m.setSubject("[Troostwijk] " + title);
m.setText(msg);
m.setSentDate(new Date());
if (prio > 0) {
m.setHeader("X-Priority", "1");
m.setHeader("Importance", "High");
}
Transport.send(m);
log.info("Email notification sent: {}", title);
} catch (javax.mail.AuthenticationFailedException e) {
log.warn("Email authentication failed - check Gmail App Password: {}", e.getMessage());
} catch (javax.mail.MessagingException e) {
log.warn("Email connection failed (network/firewall issue?): {}", e.getMessage());
} catch (Exception e) {
log.warn("Email failed: {}", e.getMessage());
}
}
public record Config(
boolean useDesktop,
boolean useEmail,
String smtpUsername,
String smtpPassword,
String toEmail
) {
public static Config parse(String raw) {
if ("desktop".equalsIgnoreCase(raw)) {
return new Config(true, false, null, null, null);
}
if (raw != null && raw.startsWith("smtp:")) {
var p = raw.split(":", -1);
if (p.length < 4) {
throw new IllegalArgumentException("Format: smtp:username:password:toEmail");
}
return new Config(true, true, p[1], p[2], p[3]);
}
throw new IllegalArgumentException("Use 'desktop' or 'smtp:username:password:toEmail'");
}
}
private static boolean isUnderTest() {
try {
// Explicit override
if (Boolean.getBoolean("auctiora.test")) return true;
// Maven Surefire commonly sets this property
if (System.getProperty("surefire.test.class.path") != null) return true;
// Fallback: check classpath hint
String cp = System.getProperty("java.class.path", "");
return cp.contains("surefire") || cp.contains("junit");
} catch (Exception ignored) {
return false;
}
}
}

View File

@@ -0,0 +1,244 @@
package auctiora;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import lombok.extern.slf4j.Slf4j;
import nu.pattern.OpenCV;
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.dnn.Dnn;
import org.opencv.dnn.Net;
import org.opencv.imgcodecs.Imgcodecs;
import java.io.Console;
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;
import static org.opencv.dnn.Dnn.DNN_BACKEND_OPENCV;
import static org.opencv.dnn.Dnn.DNN_TARGET_CPU;
/**
* Service for performing object detection on images using OpenCV's DNN
* module. The DNN module can load pretrained models from several
* frameworks (Darknet, TensorFlow, ONNX, etc.). Here
* we load a YOLO model (Darknet) by specifying the configuration and
* weights files. For each image we run a forward pass and return a
* list of detected class labels.
*
* If model files are not found, the service operates in disabled mode
* and returns empty lists.
*/
@Slf4j
public class ObjectDetectionService {
private Net net;
private List<String> classNames;
private boolean enabled;
private int warnCount = 0;
private static final int MAX_WARNINGS = 5;
private static boolean openCvLoaded = false;
private final String cfgPath;
private final String weightsPath;
private final String classNamesPath;
ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
this.cfgPath = cfgPath;
this.weightsPath = weightsPath;
this.classNamesPath = classNamesPath;
}
@PostConstruct
void init() {
// Load OpenCV native libraries first
if (!openCvLoaded) {
try {
OpenCV.loadLocally();
openCvLoaded = true;
log.info("✓ OpenCV {} loaded successfully", Core.VERSION);
} catch (Exception e) {
log.warn("⚠️ Object detection disabled: OpenCV native libraries not loaded");
enabled = false;
net = null;
classNames = new ArrayList<>();
return;
}
}
initializeModel();
}
private void initializeModel() {
// Check if model files exist
var cfgFile = Paths.get(cfgPath);
var weightsFile = Paths.get(weightsPath);
var classNamesFile = Paths.get(classNamesPath);
if (!Files.exists(cfgFile) || !Files.exists(weightsFile) || !Files.exists(classNamesFile)) {
log.info("⚠️ Object detection disabled: YOLO model files not found");
log.info(" Expected files:");
log.info(" - {}", cfgPath);
log.info(" - {}", weightsPath);
log.info(" - {}", classNamesPath);
log.info(" Scraper will continue without image analysis.");
enabled = false;
net = null;
classNames = new ArrayList<>();
return;
}
try {
// Load network
net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
// Try to use GPU/CUDA if available, fallback to CPU
try {
net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA);
net.setPreferableTarget(Dnn.DNN_TARGET_CUDA);
log.info("✓ Object detection enabled with YOLO (CUDA/GPU acceleration)");
} catch (Exception e) {
// CUDA not available, try Vulkan for AMD GPUs
try {
net.setPreferableBackend(Dnn.DNN_BACKEND_VKCOM);
net.setPreferableTarget(Dnn.DNN_TARGET_VULKAN);
log.info("✓ Object detection enabled with YOLO (Vulkan/GPU acceleration)");
} catch (Exception e2) {
// GPU not available, fallback to CPU
net.setPreferableBackend(DNN_BACKEND_OPENCV);
net.setPreferableTarget(DNN_TARGET_CPU);
log.info("✓ Object detection enabled with YOLO (CPU only)");
}
}
// Load class names (one per line)
classNames = Files.readAllLines(classNamesFile);
enabled = true;
} catch (UnsatisfiedLinkError e) {
log.error("⚠️ Object detection disabled: OpenCV native libraries not loaded", e);
enabled = false;
net = null;
classNames = new ArrayList<>();
} catch (Exception e) {
log.error("⚠️ Object detection disabled: " + e.getMessage(), e);
enabled = false;
net = null;
classNames = new ArrayList<>();
}
}
/**
* Detects objects in the given image file and returns a list of
* humanreadable labels. Only detections above a confidence
* threshold are returned. For brevity this method omits drawing
* bounding boxes. See the OpenCV DNN documentation for details on
* postprocessing【784097309529506†L324-L344】.
*
* @param imagePath absolute path to the image
* @return list of detected class names (empty if detection disabled)
*/
List<String> detectObjects(String imagePath) {
if (!enabled) {
return new ArrayList<>();
}
List<String> labels = new ArrayList<>();
var image = Imgcodecs.imread(imagePath);
if (image.empty()) return labels;
// Create a 4D blob from the image
var blob = Dnn.blobFromImage(image, 1.0 / 255.0, new Size(416, 416), new Scalar(0, 0, 0), true, false);
net.setInput(blob);
List<Mat> outs = new ArrayList<>();
var outNames = getOutputLayerNames(net);
net.forward(outs, outNames);
// Postprocess: for each detection compute score and choose class
var confThreshold = 0.5f;
for (var out : outs) {
// YOLO output shape: [num_detections, 85] where 85 = 4 (bbox) + 1 (objectness) + 80 (classes)
int numDetections = out.rows();
int numElements = out.cols();
int expectedLength = 5 + classNames.size();
if (numElements < expectedLength) {
// Rate-limit warnings to prevent thread blocking from excessive logging
if (warnCount < MAX_WARNINGS) {
log.warn("Output matrix has wrong dimensions: expected {} columns, got {}. Output shape: [{}, {}]",
expectedLength, numElements, numDetections, numElements);
warnCount++;
if (warnCount == MAX_WARNINGS) {
log.warn("Suppressing further dimension warnings (reached {} warnings)", MAX_WARNINGS);
}
}
continue;
}
for (var i = 0; i < numDetections; i++) {
// Get entire row (all 85 elements)
var data = new double[numElements];
for (int j = 0; j < numElements; j++) {
data[j] = out.get(i, j)[0];
}
// Extract objectness score (index 4) and class scores (index 5+)
double objectness = data[4];
if (objectness < confThreshold) {
continue; // Skip low-confidence detections
}
// Extract class scores
var scores = new double[classNames.size()];
System.arraycopy(data, 5, scores, 0, Math.min(scores.length, data.length - 5));
var classId = argMax(scores);
var confidence = scores[classId] * objectness; // Combine objectness with class confidence
if (confidence > confThreshold) {
var label = classNames.get(classId);
if (!labels.contains(label)) {
labels.add(label);
}
}
}
}
// Release resources
image.release();
blob.release();
for (var out : outs) {
out.release();
}
return labels;
}
/**
* Returns the indexes of the output layers in the network. YOLO
* automatically discovers its output layers; other models may require
* manually specifying them【784097309529506†L356-L365】.
*/
private List<String> getOutputLayerNames(Net net) {
List<String> names = new ArrayList<>();
var outLayers = net.getUnconnectedOutLayers().toList();
var layersNames = net.getLayerNames();
for (var i : outLayers) {
names.add(layersNames.get(i - 1));
}
return names;
}
/**
* Returns the index of the maximum value in the array.
*/
private int argMax(double[] array) {
var best = 0;
var max = array[0];
for (var i = 1; i < array.length; i++) {
if (array[i] > max) {
max = array[i];
best = i;
}
}
return best;
}
}

View File

@@ -0,0 +1,309 @@
package auctiora;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.util.List;
/**
* Quarkus-based Workflow Scheduler using @Scheduled annotations.
* Replaces the manual ScheduledExecutorService with Quarkus Scheduler.
*
* This class coordinates all scheduled workflows using Quarkus's built-in
* scheduling capabilities with cron expressions.
*/
@ApplicationScoped
public class QuarkusWorkflowScheduler {
private static final Logger LOG = Logger.getLogger(QuarkusWorkflowScheduler.class);
@Inject DatabaseService db;
@Inject NotificationService notifier;
@Inject ObjectDetectionService detector;
@Inject ImageProcessingService imageProcessor;
@Inject LotEnrichmentService enrichmentService;
@ConfigProperty(name = "auction.database.path") String databasePath;
/**
* Triggered on application startup to enrich existing lots with bid intelligence
*/
void onStart(@Observes StartupEvent ev) {
LOG.info("🚀 Application started - triggering initial lot enrichment...");
// Run enrichment in background thread to not block startup
new Thread(() -> {
try {
Thread.sleep(5000); // Wait 5 seconds for application to fully start
LOG.info("Starting full lot enrichment in background...");
int enriched = enrichmentService.enrichAllActiveLots();
LOG.infof("✓ Startup enrichment complete: %d lots enriched", enriched);
} catch (Exception e) {
LOG.errorf(e, "❌ Startup enrichment failed: %s", e.getMessage());
}
}).start();
}
/**
* Workflow 1: Import Scraper Data
* Cron: Every 30 minutes (0 -/30 - - - ?)
* Purpose: Import new auctions and lots from external scraper
*/
@Scheduled(cron = "{auction.workflow.scraper-import.cron}", identity = "scraper-import")
void importScraperData() {
try {
LOG.info("📥 [WORKFLOW 1] Importing scraper data...");
var start = System.currentTimeMillis();
// Import auctions
var auctions = db.importAuctionsFromScraper();
LOG.infof(" → Imported %d auctions", auctions.size());
// Import lots
var lots = db.importLotsFromScraper();
LOG.infof(" → Imported %d lots", lots.size());
// Check for images needing detection
var images = db.getImagesNeedingDetection();
LOG.infof(" → Found %d images needing detection", images.size());
var duration = System.currentTimeMillis() - start;
LOG.infof(" ✓ Scraper import completed in %dms", duration);
// 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) {
LOG.errorf(e, " ❌ Scraper import failed: %s", e.getMessage());
}
}
/**
* Workflow 2: Process Pending Images
* Cron: Every 1 hour (0 0 * * * ?)
* Purpose: Run object detection on images already downloaded by scraper
*/
@Scheduled(cron = "{auction.workflow.image-processing.cron}", identity = "image-processing")
void processImages() {
try {
LOG.info("🖼️ [WORKFLOW 2] Processing pending images...");
var start = System.currentTimeMillis();
// Get images that have been downloaded but need object detection
var pendingImages = db.getImagesNeedingDetection();
if (pendingImages.isEmpty()) {
LOG.info(" → No pending images to process");
return;
}
// Limit batch size to prevent thread blocking (max 100 images per run)
final int MAX_BATCH_SIZE = 100;
int totalPending = pendingImages.size();
if (totalPending > MAX_BATCH_SIZE) {
LOG.infof(" → Found %d pending images, processing first %d (batch limit)",
totalPending, MAX_BATCH_SIZE);
pendingImages = pendingImages.subList(0, MAX_BATCH_SIZE);
} else {
LOG.infof(" → Processing %d images", totalPending);
}
var processed = 0;
var detected = 0;
var failed = 0;
for (var image : pendingImages) {
try {
// Run object detection on already-downloaded image
if (imageProcessor.processImage(image.id(), image.filePath(), image.lotId())) {
processed++;
// Check if objects were detected
var labels = db.getImageLabels(image.id());
if (labels != null && !labels.isEmpty()) {
detected++;
// Send notification for interesting detections
if (labels.size() >= 3) {
notifier.sendNotification(
String.format("Lot %d: Detected %s",
image.lotId(),
String.join(", ", labels)),
"Objects Detected",
0
);
}
}
} else {
failed++;
}
// Rate limiting (lighter since no network I/O)
Thread.sleep(100);
} catch (Exception e) {
failed++;
LOG.warnf(" ⚠️ Failed to process image: %s", e.getMessage());
}
}
var duration = System.currentTimeMillis() - start;
LOG.infof(" ✓ Processed %d/%d images, detected objects in %d, failed %d (%.1fs)",
processed, totalPending, detected, failed, duration / 1000.0);
if (totalPending > MAX_BATCH_SIZE) {
LOG.infof(" → %d images remaining for next run", totalPending - MAX_BATCH_SIZE);
}
} catch (Exception e) {
LOG.errorf(e, " ❌ Image processing failed: %s", e.getMessage());
}
}
/**
* Workflow 3: Monitor Bids
* Cron: Every 15 minutes (0 -/15 * * * ?)
* Purpose: Check for bid changes and send notifications
*/
@Scheduled(cron = "{auction.workflow.bid-monitoring.cron}", identity = "bid-monitoring")
void monitorBids() {
try {
LOG.info("💰 [WORKFLOW 3] Monitoring bids...");
var start = System.currentTimeMillis();
var activeLots = db.getActiveLots();
LOG.infof(" → Checking %d active lots", activeLots.size());
// 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
var duration = System.currentTimeMillis() - start;
LOG.infof(" ✓ Bid monitoring completed in %dms", duration);
} catch (Exception e) {
LOG.errorf(e, " ❌ Bid monitoring failed: %s", e.getMessage());
}
}
/**
* Workflow 4: Check Closing Times
* Cron: Every 5 minutes (0 -/5 * * * ?)
* Purpose: Send alerts for lots closing soon
*/
@Scheduled(cron = "{auction.workflow.closing-alerts.cron}", identity = "closing-alerts")
void checkClosingTimes() {
try {
LOG.info("⏰ [WORKFLOW 4] Checking closing times...");
var start = System.currentTimeMillis();
var activeLots = db.getActiveLots();
var alertsSent = 0;
for (var lot : activeLots) {
if (lot.closingTime() == null) continue;
var minutesLeft = lot.minutesUntilClose();
// Alert for lots closing in 5 minutes
if (minutesLeft <= 5 && minutesLeft > 0 && !lot.closingNotified()) {
var message = String.format("Kavel %d sluit binnen %d min.",
lot.lotId(), minutesLeft);
notifier.sendNotification(message, "Lot Closing Soon", 1);
// Mark as notified
var updated = Lot.basic(
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++;
}
}
var duration = System.currentTimeMillis() - start;
LOG.infof(" → Sent %d closing alerts in %dms", alertsSent, duration);
} catch (Exception e) {
LOG.errorf(e, " ❌ Closing alerts failed: %s", e.getMessage());
}
}
/**
* Event-driven trigger: New auction discovered
*/
public void onNewAuctionDiscovered(AuctionInfo auction) {
LOG.infof("📣 EVENT: New auction discovered - %s", 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) {
LOG.errorf(e, " ❌ Failed to handle new auction: %s", e.getMessage());
}
}
/**
* Event-driven trigger: Bid change detected
*/
public void onBidChange(Lot lot, double previousBid, double newBid) {
LOG.infof("📣 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) {
LOG.errorf(e, " ❌ Failed to handle bid change: %s", e.getMessage());
}
}
/**
* Event-driven trigger: Objects detected in image
*/
public void onObjectsDetected(int lotId, List<String> labels) {
LOG.infof("📣 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) {
LOG.errorf(e, " ❌ Failed to send detection notification: %s", e.getMessage());
}
}
}

View File

@@ -0,0 +1,246 @@
package auctiora;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Rate-limited HTTP client that enforces per-host request limits.
*
* Features:
* - Per-host rate limiting (configurable max requests per second)
* - Request counting and monitoring
* - Thread-safe using semaphores
* - Automatic host extraction from URLs
*
* This prevents overloading external services like Troostwijk and getting blocked.
*/
@ApplicationScoped
public class RateLimitedHttpClient {
private static final Logger LOG = Logger.getLogger(RateLimitedHttpClient.class);
private final HttpClient httpClient;
private final Map<String, RateLimiter> rateLimiters;
private final Map<String, RequestStats> requestStats;
@ConfigProperty(name = "auction.http.rate-limit.default-max-rps", defaultValue = "2")
int defaultMaxRequestsPerSecond;
@ConfigProperty(name = "auction.http.rate-limit.troostwijk-max-rps", defaultValue = "1")
int troostwijkMaxRequestsPerSecond;
@ConfigProperty(name = "auction.http.timeout-seconds", defaultValue = "30")
int timeoutSeconds;
public RateLimitedHttpClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
this.rateLimiters = new ConcurrentHashMap<>();
this.requestStats = new ConcurrentHashMap<>();
}
/**
* Sends a GET request with automatic rate limiting based on host.
*/
public HttpResponse<String> sendGet(String url) throws IOException, InterruptedException {
var request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.GET()
.build();
return send(request, HttpResponse.BodyHandlers.ofString());
}
/**
* Sends a request for binary data (like images) with rate limiting.
*/
public HttpResponse<byte[]> sendGetBytes(String url) throws IOException, InterruptedException {
var request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.GET()
.build();
return send(request, HttpResponse.BodyHandlers.ofByteArray());
}
/**
* Sends any HTTP request with automatic rate limiting.
*/
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> bodyHandler)
throws IOException, InterruptedException {
var host = extractHost(request.uri());
var limiter = getRateLimiter(host);
var stats = getRequestStats(host);
// Enforce rate limit (blocks if necessary)
limiter.acquire();
// Track request
stats.incrementTotal();
var startTime = System.currentTimeMillis();
try {
var response = httpClient.send(request, bodyHandler);
var duration = System.currentTimeMillis() - startTime;
stats.recordSuccess(duration);
LOG.debugf("HTTP %d %s %s (%dms)",
response.statusCode(), request.method(), host, duration);
// Track rate limit violations (429 = Too Many Requests)
if (response.statusCode() == 429) {
stats.incrementRateLimited();
LOG.warnf("⚠️ Rate limited by %s (HTTP 429)", host);
}
return response;
} catch (IOException | InterruptedException e) {
stats.incrementFailed();
LOG.warnf("❌ HTTP request failed for %s: %s", host, e.getMessage());
throw e;
}
}
/**
* Gets or creates a rate limiter for a specific host.
*/
private RateLimiter getRateLimiter(String host) {
return rateLimiters.computeIfAbsent(host, h -> {
var maxRps = getMaxRequestsPerSecond(h);
LOG.infof("Initializing rate limiter for %s: %d req/s", h, maxRps);
return new RateLimiter(maxRps);
});
}
/**
* Gets or creates request stats for a specific host.
*/
private RequestStats getRequestStats(String host) {
return requestStats.computeIfAbsent(host, h -> new RequestStats(h));
}
/**
* Determines max requests per second for a given host.
*/
private int getMaxRequestsPerSecond(String host) {
return host.contains("troostwijk") ? troostwijkMaxRequestsPerSecond : defaultMaxRequestsPerSecond;
}
private String extractHost(URI uri) {
return uri.getHost() != null ? uri.getHost() : uri.toString();
}
public Map<String, RequestStats> getAllStats() {
return Map.copyOf(requestStats);
}
public RequestStats getStats(String host) {
return requestStats.get(host);
}
/**
* Rate limiter implementation using token bucket algorithm.
* Allows burst traffic up to maxRequestsPerSecond, then enforces steady rate.
*/
private static class RateLimiter {
private final Semaphore semaphore;
private final int maxRequestsPerSecond;
private final long intervalNanos;
RateLimiter(int maxRequestsPerSecond) {
this.maxRequestsPerSecond = maxRequestsPerSecond;
this.intervalNanos = TimeUnit.SECONDS.toNanos(1) / maxRequestsPerSecond;
this.semaphore = new Semaphore(maxRequestsPerSecond);
// Refill tokens periodically
startRefillThread();
}
void acquire() throws InterruptedException {
semaphore.acquire();
// Enforce minimum delay between requests
var delayMillis = intervalNanos / 1_000_000;
if (delayMillis > 0) {
Thread.sleep(delayMillis);
}
}
private void startRefillThread() {
var refillThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(1000); // Refill every second
var toRelease = maxRequestsPerSecond - semaphore.availablePermits();
if (toRelease > 0) {
semaphore.release(toRelease);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}, "RateLimiter-Refill");
refillThread.setDaemon(true);
refillThread.start();
}
}
public static final class RequestStats {
private final String host;
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong successfulRequests = new AtomicLong(0);
private final AtomicLong failedRequests = new AtomicLong(0);
private final AtomicLong rateLimitedRequests = new AtomicLong(0);
private final AtomicLong totalDurationMs = new AtomicLong(0);
RequestStats(String host) {
this.host = host;
}
void incrementTotal() { totalRequests.incrementAndGet(); }
void recordSuccess(long durationMs) {
successfulRequests.incrementAndGet();
totalDurationMs.addAndGet(durationMs);
}
void incrementFailed() { failedRequests.incrementAndGet(); }
void incrementRateLimited() { rateLimitedRequests.incrementAndGet(); }
public String getHost() { return host; }
public long getTotalRequests() { return totalRequests.get(); }
public long getSuccessfulRequests() { return successfulRequests.get(); }
public long getFailedRequests() { return failedRequests.get(); }
public long getRateLimitedRequests() { return rateLimitedRequests.get(); }
public long getAverageDurationMs() {
var successful = successfulRequests.get();
return successful > 0 ? totalDurationMs.get() / successful : 0;
}
@Override
public String toString() {
return String.format("%s: %d total, %d success, %d failed, %d rate-limited, avg %dms",
host, getTotalRequests(), getSuccessfulRequests(),
getFailedRequests(), getRateLimitedRequests(), getAverageDurationMs());
}
}
}

View File

@@ -0,0 +1,196 @@
package auctiora;
import lombok.extern.slf4j.Slf4j;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
@Slf4j
public class ScraperDataAdapter {
private static final DateTimeFormatter[] TIMESTAMP_FORMATS = {
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ISO_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
};
static AuctionInfo fromScraperAuction(ResultSet rs) throws SQLException {
// Parse "A7-39813" → auctionId=39813, type="A7"
var auctionIdStr = rs.getString("auction_id");
var auctionId = extractNumericId(auctionIdStr);
var type = extractTypePrefix(auctionIdStr);
// Split "Cluj-Napoca, RO" → city="Cluj-Napoca", country="RO"
var location = rs.getString("location");
var locationParts = parseLocation(location);
var city = locationParts[0];
var country = locationParts[1];
// Map field names
var lotCount = getIntOrDefault(rs, "lots_count", 0);
var closingTime = parseTimestamp(getStringOrNull(rs, "first_lot_closing_time"));
return new AuctionInfo(
auctionId,
rs.getString("title"),
location,
city,
country,
rs.getString("url"),
type,
lotCount,
closingTime
);
}
public static Lot fromScraperLot(ResultSet rs) throws SQLException {
var lotIdStr = rs.getString("lot_id"); // Full display ID (e.g., "A1-34732-49")
var lotId = extractNumericId(lotIdStr);
var saleId = extractNumericId(rs.getString("auction_id"));
var bidStr = getStringOrNull(rs, "current_bid");
var bid = parseBidAmount(bidStr);
var currency = parseBidCurrency(bidStr);
var closing = parseTimestamp(getStringOrNull(rs, "closing_time"));
return new Lot(
saleId,
lotId,
lotIdStr, // Store full displayId for GraphQL queries
rs.getString("title"),
getStringOrDefault(rs, "description", ""),
getStringOrDefault(rs, "manufacturer", ""),
getStringOrDefault(rs, "type", ""),
getIntOrDefault(rs, "year", 0),
getStringOrDefault(rs, "category", ""),
bid,
currency,
rs.getString("url"),
closing,
getBooleanOrDefault(rs, "closing_notified", false),
// New intelligence fields - set to null for now
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null
);
}
public static long extractNumericId(String id) {
if (id == null || id.isBlank()) return 0L;
// Remove the type prefix (e.g., "A7-") first, then extract all digits
// "A7-39813" → "39813" → 39813
// "A1-28505-5" → "28505-5" → "285055"
var afterPrefix = id.indexOf('-') >= 0 ? id.substring(id.indexOf('-') + 1) : id;
var digits = afterPrefix.replaceAll("\\D+", "");
if (digits.isEmpty()) return 0L;
// Check if number is too large for long (> 19 digits or value > Long.MAX_VALUE)
if (digits.length() > 19) {
log.debug("ID too large for long, skipping: {}", id);
return 0L;
}
try {
return Long.parseLong(digits);
} catch (NumberFormatException e) {
log.debug("Invalid numeric ID, skipping: {}", id);
return 0L;
}
}
private static String extractTypePrefix(String id) {
if (id == null) return "";
var idx = id.indexOf('-');
return idx > 0 ? id.substring(0, idx) : "";
}
private static String[] parseLocation(String location) {
if (location == null || location.isBlank()) return new String[]{ "", "" };
var parts = location.split(",\\s*");
var city = parts[0].trim();
var country = parts.length > 1 ? parts[parts.length - 1].trim() : "";
return new String[]{ city, country };
}
private static double parseBidAmount(String bid) {
if (bid == null || bid.isBlank() || bid.toLowerCase().contains("no")) return 0.0;
var cleaned = bid.replaceAll("[^0-9.]", "");
try {
return cleaned.isEmpty() ? 0.0 : Double.parseDouble(cleaned);
} catch (NumberFormatException e) {
return 0.0;
}
}
private static String parseBidCurrency(String bid) {
if (bid == null) return "EUR";
return bid.contains("") ? "EUR"
: bid.contains("$") ? "USD"
: bid.contains("£") ? "GBP"
: "EUR";
}
public static LocalDateTime parseTimestamp(String ts) {
if (ts == null || ts.isBlank()) return null;
String trimmed = ts.trim();
String tsLower = trimmed.toLowerCase();
// Filter out known invalid values
if (tsLower.equals("gap") || tsLower.equals("null") || tsLower.equals("n/a") ||
tsLower.equals("unknown") || tsLower.equals("tbd")) {
log.debug("Skipping invalid timestamp value: {}", ts);
return null;
}
// Recognize numeric epoch values (seconds or milliseconds)
if (trimmed.matches("^[0-9]{10,16}$")) {
try {
long epoch = Long.parseLong(trimmed);
java.time.Instant instant;
// Heuristic: 13 digits (>= 10^12) => milliseconds, else seconds
if (trimmed.length() >= 13) {
instant = java.time.Instant.ofEpochMilli(epoch);
} else {
instant = java.time.Instant.ofEpochSecond(epoch);
}
return java.time.LocalDateTime.ofInstant(instant, java.time.ZoneId.systemDefault());
} catch (NumberFormatException e) {
// fall through to formatter parsing
}
}
// Try known text formats (ISO first)
for (var fmt : TIMESTAMP_FORMATS) {
try {
return LocalDateTime.parse(trimmed, fmt);
} catch (DateTimeParseException ignored) { }
}
log.debug("Unable to parse timestamp: {}", ts);
return null;
}
private static String getStringOrNull(ResultSet rs, String col) throws SQLException {
return rs.getString(col);
}
private static String getStringOrDefault(ResultSet rs, String col, String def) throws SQLException {
var v = rs.getString(col);
return v != null ? v : def;
}
private static int getIntOrDefault(ResultSet rs, String col, int def) throws SQLException {
var v = rs.getInt(col);
return rs.wasNull() ? def : v;
}
private static boolean getBooleanOrDefault(ResultSet rs, String col, boolean def) throws SQLException {
var v = rs.getInt(col);
return rs.wasNull() ? def : v != 0;
}
}

View File

@@ -0,0 +1,74 @@
package auctiora;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.extern.slf4j.Slf4j;
import nu.pattern.OpenCV;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.opencv.core.Core;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@Slf4j
@Path("/api")
public class StatusResource {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")
.withZone(ZoneId.systemDefault());
@ConfigProperty(name = "application.version", defaultValue = "1.0-SNAPSHOT") String appVersion;
@ConfigProperty(name = "application.groupId") String groupId;
@ConfigProperty(name = "application.artifactId") String artifactId;
@ConfigProperty(name = "application.version") String version;
public record StatusResponse(
String groupId,
String artifactId,
String version,
String status,
String timestamp,
String mvnVersion,
String javaVersion,
String os,
String openCvVersion
) { }
@GET
@Path("/status")
@Produces(MediaType.APPLICATION_JSON)
public StatusResponse getStatus() {
return new StatusResponse(groupId, artifactId, version,
"running",
FORMATTER.format(Instant.now()),
appVersion,
System.getProperty("java.version"),
System.getProperty("os.name") + " " + System.getProperty("os.arch"),
getOpenCvVersion()
);
}
@GET
@Path("/hello")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, String> sayHello() {
return Map.of(
"message", "Hello from Scrape-UI!",
"timestamp", FORMATTER.format(Instant.now()),
"openCvVersion", getOpenCvVersion()
);
}
private String getOpenCvVersion() {
try {
// OpenCV is already loaded by AuctionMonitorProducer
return Core.VERSION;
} catch (Exception e) {
return "Not loaded";
}
}
}

View File

@@ -0,0 +1,378 @@
package auctiora;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* GraphQL client for fetching enriched lot data from Troostwijk API.
* Fetches intelligence fields: followers, estimates, bid velocity, condition, etc.
*/
@Slf4j
@ApplicationScoped
public class TroostwijkGraphQLClient {
private static final String GRAPHQL_ENDPOINT = "https://storefront.tbauctions.com/storefront/graphql";
private static final String LOCALE = "nl";
private static final String PLATFORM = "TWK";
private static final ObjectMapper objectMapper = new ObjectMapper();
@Inject
RateLimitedHttpClient rateLimitedClient;
/**
* Fetches enriched lot data from GraphQL API
* @param displayId The lot display ID (e.g., "A1-34732-49")
* @param lotId The numeric lot ID for mapping back to database
* @return LotIntelligence with enriched fields, or null if failed
*/
public LotIntelligence fetchLotIntelligence(String displayId, long lotId) {
if (displayId == null || displayId.isBlank()) {
log.debug("Cannot fetch intelligence for null/blank displayId");
return null;
}
try {
var query = buildLotQuery();
var variables = buildVariables(displayId);
// Proper GraphQL request format with query and variables
var requestBody = String.format(
"{\"query\":\"%s\",\"variables\":%s}",
escapeJson(query),
variables
);
var request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestBody))
.build();
var response = rateLimitedClient.send(
request,
java.net.http.HttpResponse.BodyHandlers.ofString()
);
if (response == null || response.body() == null) {
log.debug("No response from GraphQL for lot {}", displayId);
return null;
}
log.debug("GraphQL response for lot {}: {}", displayId, response.body().substring(0, Math.min(200, response.body().length())));
return parseLotIntelligence(response.body(), lotId);
} catch (Exception e) {
log.warn("Failed to fetch lot intelligence for {}: {}", lotId, e.getMessage());
return null;
}
}
/**
* Batch fetch multiple lots in a single query (more efficient)
*/
public List<LotIntelligence> fetchBatchLotIntelligence(List<Long> lotIds) {
List<LotIntelligence> results = new ArrayList<>();
// Split into batches of 50 to avoid query size limits
var batchSize = 50;
for (var i = 0; i < lotIds.size(); i += batchSize) {
var end = Math.min(i + batchSize, lotIds.size());
var batch = lotIds.subList(i, end);
try {
var query = buildBatchLotQuery(batch);
var requestBody = String.format("{\"query\":\"%s\"}",
escapeJson(query));
var request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
.header("Content-Type", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestBody))
.build();
var response = rateLimitedClient.send(
request,
java.net.http.HttpResponse.BodyHandlers.ofString()
);
if (response != null && response.body() != null) {
results.addAll(parseBatchLotIntelligence(response.body(), batch));
}
} catch (Exception e) {
log.warn("Failed to fetch batch lot intelligence: {}", e.getMessage());
}
}
return results;
}
private String buildLotQuery() {
// Match Python scraper's LOT_BIDDING_QUERY structure
// Uses lotDetails(displayId:...) instead of lot(id:...)
return """
query LotBiddingData($lotDisplayId: String!, $locale: String!, $platform: Platform!) {
lotDetails(displayId: $lotDisplayId, locale: $locale, platform: $platform) {
id
displayId
followersCount
currentBidInCents
nextBidStepInCents
condition
description
biddingStatus
buyersPremium
viewCount
estimatedValueInCentsMin
estimatedValueInCentsMax
categoryPath
location {
city
country
}
biddingStatistics {
numberOfBids
}
}
}
""".replaceAll("\\s+", " ");
}
private String buildVariables(String displayId) {
return String.format("""
{
"lotDisplayId": "%s",
"locale": "%s",
"platform": "%s"
}
""", displayId, LOCALE, PLATFORM).replaceAll("\\s+", " ");
}
private String buildBatchLotQuery(List<Long> lotIds) {
var query = new StringBuilder("query {");
for (var i = 0; i < lotIds.size(); i++) {
query.append(String.format("""
lot%d: lot(id: %d) {
id
followersCount
estimatedMin
estimatedMax
nextBidStepInCents
condition
categoryPath
city
countryCode
biddingStatus
vat
buyerPremiumPercentage
viewCount
bidsCount
}
""", i, lotIds.get(i)));
}
query.append("}");
return query.toString().replaceAll("\\s+", " ");
}
private LotIntelligence parseLotIntelligence(String json, long lotId) {
try {
// Check if response is HTML (error page) instead of JSON
if (json != null && json.trim().startsWith("<")) {
log.debug("GraphQL API returned HTML instead of JSON - likely auth required or wrong endpoint");
return null;
}
var root = objectMapper.readTree(json);
var lotNode = root.path("data").path("lotDetails");
if (lotNode.isMissingNode()) {
log.debug("No lotDetails in GraphQL response");
return null;
}
// Extract location from nested object
var locationNode = lotNode.path("location");
var city = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "city");
var countryCode = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "country");
// Extract bids count from nested biddingStatistics
var statsNode = lotNode.path("biddingStatistics");
var bidsCount = statsNode.isMissingNode() ? null : getIntOrNull(statsNode, "numberOfBids");
// Convert cents to euros for estimates
var estimatedMinCents = getLongOrNull(lotNode, "estimatedValueInCentsMin");
var estimatedMaxCents = getLongOrNull(lotNode, "estimatedValueInCentsMax");
var estimatedMin = estimatedMinCents != null ? estimatedMinCents.doubleValue() : null;
var estimatedMax = estimatedMaxCents != null ? estimatedMaxCents.doubleValue() : null;
return new LotIntelligence(
lotId,
getIntOrNull(lotNode, "followersCount"),
estimatedMin,
estimatedMax,
getLongOrNull(lotNode, "nextBidStepInCents"),
getStringOrNull(lotNode, "condition"),
getStringOrNull(lotNode, "categoryPath"),
city,
countryCode,
getStringOrNull(lotNode, "biddingStatus"),
null, // appearance - not in API response
null, // packaging - not in API response
null, // quantity - not in API response
null, // vat - not in API response
null, // buyerPremiumPercentage - could extract from buyersPremium
null, // remarks - not in API response
null, // startingBid - not in API response
null, // reservePrice - not in API response
null, // reserveMet - not in API response
null, // bidIncrement - not in API response
getIntOrNull(lotNode, "viewCount"),
null, // firstBidTime - not in API response
null, // lastBidTime - not in API response
null // bidVelocity - could calculate from bidsCount if we had timing data
);
} catch (Exception e) {
log.warn("Failed to parse lot intelligence: {}", e.getMessage());
return null;
}
}
private List<LotIntelligence> parseBatchLotIntelligence(String json, List<Long> lotIds) {
List<LotIntelligence> results = new ArrayList<>();
try {
var root = objectMapper.readTree(json);
var data = root.path("data");
for (var i = 0; i < lotIds.size(); i++) {
var lotNode = data.path("lot" + i);
if (!lotNode.isMissingNode()) {
var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i));
if (intelligence != null) {
results.add(intelligence);
}
}
}
} catch (Exception e) {
log.warn("Failed to parse batch lot intelligence: {}", e.getMessage());
}
return results;
}
private LotIntelligence parseLotIntelligenceFromNode(JsonNode lotNode, long lotId) {
try {
return new LotIntelligence(
lotId,
getIntOrNull(lotNode, "followersCount"),
getDoubleOrNull(lotNode, "estimatedMin"),
getDoubleOrNull(lotNode, "estimatedMax"),
getLongOrNull(lotNode, "nextBidStepInCents"),
getStringOrNull(lotNode, "condition"),
getStringOrNull(lotNode, "categoryPath"),
getStringOrNull(lotNode, "city"),
getStringOrNull(lotNode, "countryCode"),
getStringOrNull(lotNode, "biddingStatus"),
null, // appearance not in batch query
null, // packaging not in batch query
null, // quantity not in batch query
getDoubleOrNull(lotNode, "vat"),
getDoubleOrNull(lotNode, "buyerPremiumPercentage"),
null, // remarks not in batch query
null, // startingBid not in batch query
null, // reservePrice not in batch query
null, // reserveMet not in batch query
null, // bidIncrement not in batch query
getIntOrNull(lotNode, "viewCount"),
null, // firstBidTime not in batch query
null, // lastBidTime not in batch query
calculateBidVelocity(lotNode)
);
} catch (Exception e) {
log.warn("Failed to parse lot node: {}", e.getMessage());
return null;
}
}
private Double calculateBidVelocity(JsonNode lotNode) {
try {
var bidsCount = getIntOrNull(lotNode, "bidsCount");
var firstBidStr = getStringOrNull(lotNode, "firstBidTime");
if (bidsCount == null || firstBidStr == null || bidsCount == 0) {
return null;
}
var firstBid = parseDateTime(firstBidStr);
if (firstBid == null) return null;
var hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours();
if (hoursElapsed == 0) return (double) bidsCount;
return (double) bidsCount / hoursElapsed;
} catch (Exception e) {
return null;
}
}
private LocalDateTime parseDateTime(String dateStr) {
if (dateStr == null || dateStr.isBlank()) return null;
try {
return LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_DATE_TIME);
} catch (Exception e) {
return null;
}
}
private String escapeJson(String str) {
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
private Integer getIntOrNull(JsonNode node, String field) {
var fieldNode = node.path(field);
return fieldNode.isNumber() ? fieldNode.asInt() : null;
}
private Long getLongOrNull(JsonNode node, String field) {
var fieldNode = node.path(field);
return fieldNode.isNumber() ? fieldNode.asLong() : null;
}
private Double getDoubleOrNull(JsonNode node, String field) {
var fieldNode = node.path(field);
return fieldNode.isNumber() ? fieldNode.asDouble() : null;
}
private String getStringOrNull(JsonNode node, String field) {
var fieldNode = node.path(field);
return fieldNode.isTextual() ? fieldNode.asText() : null;
}
private Boolean getBooleanOrNull(JsonNode node, String field) {
var fieldNode = node.path(field);
return fieldNode.isBoolean() ? fieldNode.asBoolean() : null;
}
}

View File

@@ -0,0 +1,132 @@
package auctiora;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.experimental.FieldDefaults;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.sql.SQLException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@Slf4j
public class TroostwijkMonitor {
private static final String LOT_API = "https://api.troostwijkauctions.com/lot/7/list";
RateLimitedHttpClient httpClient;
ObjectMapper objectMapper;
@Getter DatabaseService db;
NotificationService notifier;
ObjectDetectionService detector;
ImageProcessingService imageProcessor;
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
var t = new Thread(r, "troostwijk-monitor-thread");
t.setDaemon(true);
return t;
});
public TroostwijkMonitor(String databasePath,
String notificationConfig,
String yoloCfgPath,
String yoloWeightsPath,
String classNamesPath)
throws SQLException, IOException {
httpClient = new RateLimitedHttpClient();
objectMapper = new ObjectMapper();
db = new DatabaseService(databasePath);
notifier = new NotificationService(notificationConfig);
detector = new ObjectDetectionService(yoloCfgPath, yoloWeightsPath, classNamesPath);
imageProcessor = new ImageProcessingService(db, detector);
db.ensureSchema();
}
public void scheduleMonitoring() {
scheduler.scheduleAtFixedRate(this::monitorAllLots, 0, 1, TimeUnit.HOURS);
log.info("✓ Monitoring service started");
}
private void monitorAllLots() {
try {
var activeLots = db.getActiveLots();
log.info("Monitoring {} active lots …", activeLots.size());
for (var lot : activeLots) {
checkAndUpdateLot(lot);
}
} catch (Exception e) {
log.error("Error during scheduled monitoring", e);
}
}
private void checkAndUpdateLot(Lot lot) {
refreshLotBid(lot);
var minutesLeft = lot.minutesUntilClose();
if (minutesLeft < 30) {
if (minutesLeft <= 5 && !lot.closingNotified()) {
notifier.sendNotification(
"Kavel " + lot.lotId() + " sluit binnen " + minutesLeft + " min.",
"Lot nearing closure", 1);
db.updateLotNotificationFlags(lot.withClosingNotified(true));
}
scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
}
}
private void refreshLotBid(Lot lot) {
try {
var url = LOT_API +
"?batchSize=1&listType=7&offset=0&sortOption=0" +
"&saleID=" + lot.saleId() +
"&parentID=0&relationID=0&buildversion=201807311" +
"&lotID=" + lot.lotId();
var resp = httpClient.sendGet(url);
if (resp.statusCode() != 200) return;
var root = objectMapper.readTree(resp.body());
var results = root.path("results");
if (results.isArray() && results.size() > 0) {
var newBid = results.get(0).path("cb").asDouble();
if (Double.compare(newBid, lot.currentBid()) > 0) {
var previous = lot.currentBid();
var updatedLot = lot.withCurrentBid(newBid);
db.updateLotCurrentBid(updatedLot);
var msg = String.format(
"Nieuw bod op kavel %d: €%.2f (was €%.2f)",
lot.lotId(), newBid, previous);
notifier.sendNotification(msg, "Kavel bieding update", 0);
}
}
} catch (IOException | InterruptedException e) {
log.warn("Failed to refresh bid for lot {}", lot.lotId(), e);
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
}
}
public void printDatabaseStats() {
try {
var allLots = db.getAllLots();
var imageCount = db.getImageCount();
log.info("📊 Database Summary: total lots = {}, total images = {}",
allLots.size(), imageCount);
if (!allLots.isEmpty()) {
var sum = allLots.stream().mapToDouble(Lot::currentBid).sum();
log.info("Total current bids: €{}", String.format("%.2f", sum));
}
} catch (Exception e) {
log.warn("Could not retrieve database stats", e);
}
}
public void processPendingImages() {
imageProcessor.processPendingImages();
}
}

View File

@@ -0,0 +1,378 @@
package auctiora;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* REST API for Auction Valuation Analytics
* Implements the mathematical framework for fair market value calculation,
* undervaluation detection, and bidding strategy recommendations.
*/
@Path("/api/analytics")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ValuationAnalyticsResource {
private static final Logger LOG = Logger.getLogger(ValuationAnalyticsResource.class);
@Inject
DatabaseService db;
/**
* GET /api/analytics/valuation/health
* Health check endpoint to verify API availability
*/
@GET
@Path("/valuation/health")
public Response healthCheck() {
return Response.ok(Map.of(
"status", "healthy",
"service", "valuation-analytics",
"timestamp", java.time.LocalDateTime.now().toString()
)).build();
}
/**
* POST /api/analytics/valuation
* Main valuation endpoint that calculates FMV, undervaluation score,
* predicted final price, and bidding strategy
*/
@POST
@Path("/valuation")
public Response calculateValuation(ValuationRequest request) {
try {
LOG.infof("Valuation request for lot: %s", request.lotId);
var startTime = System.currentTimeMillis();
// Step 1: Fetch comparable sales from database
var comparables = fetchComparables(request);
// Step 2: Calculate Fair Market Value (FMV)
var fmv = calculateFairMarketValue(request, comparables);
// Step 3: Calculate undervaluation score
var undervaluationScore = calculateUndervaluationScore(request, fmv.value);
// Step 4: Predict final price
var prediction = calculateFinalPrice(request, fmv.value);
// Step 5: Generate bidding strategy
var strategy = generateBiddingStrategy(request, fmv, prediction);
// Step 6: Compile response
var response = new ValuationResponse();
response.lotId = request.lotId;
response.timestamp = LocalDateTime.now().toString();
response.fairMarketValue = fmv;
response.undervaluationScore = undervaluationScore;
response.pricePrediction = prediction;
response.biddingStrategy = strategy;
response.parameters = request;
var duration = System.currentTimeMillis() - startTime;
LOG.infof("Valuation completed in %d ms", duration);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Valuation calculation failed", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* Fetches comparable lots from database based on category, manufacturer,
* year, and condition similarity
*/
private List<ComparableLot> fetchComparables(ValuationRequest req) {
// TODO: Replace with actual database query
// For now, return mock data simulating real comparables
return List.of(
new ComparableLot("NL-2023-4451", 8200.0, 8.0, 2016, 1, 30),
new ComparableLot("BE-2023-9823", 7800.0, 7.0, 2014, 0, 45),
new ComparableLot("DE-2024-1234", 8500.0, 9.0, 2017, 1, 60),
new ComparableLot("NL-2023-5678", 7500.0, 6.0, 2013, 0, 25),
new ComparableLot("BE-2024-7890", 7900.0, 7.5, 2015, 1, 15),
new ComparableLot("NL-2023-2345", 8100.0, 8.5, 2016, 0, 40),
new ComparableLot("DE-2024-4567", 8300.0, 7.0, 2015, 1, 55),
new ComparableLot("BE-2023-3456", 7700.0, 6.5, 2014, 0, 35)
);
}
/**
* Formula: FMV = Σ(P_i · ω_c · ω_t · ω_p · ω_h) / Σ(ω_c · ω_t · ω_p · ω_h)
* Where weights are exponential/logistic functions of similarity
*/
private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) {
var weightedSum = 0.0;
var weightSum = 0.0;
List<WeightedComparable> weightedComps = new ArrayList<>();
for (var comp : comparables) {
// Condition weight: ω_c = exp(-λ_c · |C_target - C_i|)
var omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore));
// Time weight: ω_t = exp(-λ_t · |T_target - T_i|)
var omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear));
// Provenance weight: ω_p = 1 + δ_p · (P_target - P_i)
var omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance);
// Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median)))
var omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40)));
var totalWeight = omegaC * omegaT * omegaP * omegaH;
weightedSum += comp.finalPrice * totalWeight;
weightSum += totalWeight;
// Store for transparency
weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH));
}
var baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2;
// Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c)
var conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40);
baseFMV *= conditionMultiplier;
// Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs))
if (req.provenanceDocs > 0) {
var provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs);
baseFMV *= (1 + provenancePremium);
}
var fmv = new FairMarketValue();
fmv.value = Math.round(baseFMV * 100.0) / 100.0;
fmv.conditionMultiplier = Math.round(conditionMultiplier * 1000.0) / 1000.0;
fmv.provenancePremium = req.provenanceDocs > 0 ? 0.08 + 0.035 * Math.log(1 + req.provenanceDocs) : 0.0;
fmv.comparablesUsed = comparables.size();
fmv.confidence = calculateFMVConfidence(comparables.size(), weightSum);
fmv.weightedComparables = weightedComps;
return fmv;
}
/**
* Calculates undervaluation score:
* U_score = (FMV - P_current)/FMV · σ_market · (1 + B_velocity/10) · ln(1 + W_watch/W_bid)
*/
private double calculateUndervaluationScore(ValuationRequest req, double fmv) {
if (fmv <= 0) return 0.0;
var priceGap = (fmv - req.currentBid) / fmv;
var velocityFactor = 1 + req.bidVelocity / 10.0;
var watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1));
var uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio;
return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0);
}
/**
* Predicts final price: P̂_final = FMV · (1 + ε_bid + ε_time + ε_comp)
* Where each epsilon represents auction dynamics
*/
private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) {
// Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/FMV)
var epsilonBid = Math.tanh(0.15 * req.bidVelocity - 0.10 * (req.currentBid / fmv));
// Time pressure error: ε_time = ψ · exp(-t_close/30)
var epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0);
// Competition error: ε_comp = ρ · ln(1 + W_watch/50)
var epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0);
var predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
// 95% confidence interval: ± 1.96 · σ_residual
var residualStdDev = fmv * 0.08; // Mock residual standard deviation
var ciLower = predictedPrice - 1.96 * residualStdDev;
var ciUpper = predictedPrice + 1.96 * residualStdDev;
var pred = new PricePrediction();
pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0;
pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0;
pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0;
pred.components = Map.of(
"bidMomentum", Math.round(epsilonBid * 1000.0) / 1000.0,
"timePressure", Math.round(epsilonTime * 1000.0) / 1000.0,
"competition", Math.round(epsilonComp * 1000.0) / 1000.0
);
return pred;
}
/**
* Generates optimal bidding strategy based on market conditions
*/
private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) {
var strategy = new BiddingStrategy();
// Determine competition level
if (req.bidVelocity > 5.0) {
strategy.competitionLevel = "HIGH";
strategy.recommendedTiming = "FINAL_30_SECONDS";
strategy.maxBid = pred.predictedPrice + 50; // Slight overbid for hot lots
strategy.riskFactors = List.of("Bidding war likely", "Sniping detected");
} else if (req.minutesUntilClose < 10) {
strategy.competitionLevel = "EXTREME";
strategy.recommendedTiming = "FINAL_10_SECONDS";
strategy.maxBid = pred.predictedPrice * 1.02;
strategy.riskFactors = List.of("Last-minute sniping", "Price volatility");
} else {
strategy.competitionLevel = "MEDIUM";
strategy.recommendedTiming = "FINAL_10_MINUTES";
// Adjust max bid based on undervaluation
var undervaluationScore = calculateUndervaluationScore(req, fmv.value);
if (undervaluationScore > 0.25) {
// Aggressive strategy for undervalued lots
strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid
strategy.analysis = "Significant undervaluation detected. Consider aggressive bidding.";
} else {
// Standard strategy
strategy.maxBid = fmv.value * (1 + 0.03);
}
strategy.riskFactors = List.of("Standard competition level");
}
// Generate detailed analysis
strategy.analysis = String.format(
"Bid velocity is %.1f bids/min with %d watchers. %s competition detected. " +
"Predicted final: €%.2f (%.0f%% confidence).",
req.bidVelocity,
req.watchCount,
strategy.competitionLevel,
pred.predictedPrice,
fmv.confidence * 100
);
// Round the max bid
strategy.maxBid = Math.round(strategy.maxBid * 100.0) / 100.0;
strategy.recommendedTimingText = strategy.recommendedTiming.replace("_", " ");
return strategy;
}
/**
* Calculates confidence score based on number and quality of comparables
*/
private double calculateFMVConfidence(int comparableCount, double totalWeight) {
var confidence = 0.5; // Base confidence
// Boost for more comparables
confidence += Math.min(comparableCount * 0.05, 0.3);
// Boost for high total weight (good matches)
confidence += Math.min(totalWeight / comparableCount * 0.1, 0.2);
// Cap at 0.95
return Math.min(confidence, 0.95);
}
// ================== DTO Classes ==================
public static class ValuationRequest {
public String lotId;
public double currentBid;
public double conditionScore; // C_target ∈ [0,10]
public int manufacturingYear; // T_target
public int watchCount; // W_watch
public int bidCount = 1; // W_bid (default 1 to avoid division by zero)
public double marketVolatility = 0.15; // σ_market ∈ [0,1]
public double bidVelocity; // Λ_b (bids/min)
public int minutesUntilClose; // t_close
public int provenanceDocs = 0; // N_docs
public double estimatedMin;
public double estimatedMax;
// Optional: override parameters for sensitivity analysis
public Map<String, Double> sensitivityParams;
}
public static class ValuationResponse {
public String lotId;
public String timestamp;
public FairMarketValue fairMarketValue;
public double undervaluationScore;
public PricePrediction pricePrediction;
public BiddingStrategy biddingStrategy;
public ValuationRequest parameters;
public long calculationTimeMs;
}
public static class FairMarketValue {
public double value;
public double conditionMultiplier;
public double provenancePremium;
public int comparablesUsed;
public double confidence; // [0,1]
public List<WeightedComparable> weightedComparables;
}
public static class WeightedComparable {
public String comparableLotId;
public double finalPrice;
public double totalWeight;
public Map<String, Double> components;
public WeightedComparable(ComparableLot comp, double totalWeight, double omegaC, double omegaT, double omegaP, double omegaH) {
this.comparableLotId = comp.lotId;
this.finalPrice = comp.finalPrice;
this.totalWeight = Math.round(totalWeight * 1000.0) / 1000.0;
this.components = Map.of(
"conditionWeight", Math.round(omegaC * 1000.0) / 1000.0,
"timeWeight", Math.round(omegaT * 1000.0) / 1000.0,
"provenanceWeight", Math.round(omegaP * 1000.0) / 1000.0,
"historicalWeight", Math.round(omegaH * 1000.0) / 1000.0
);
}
}
public static class PricePrediction {
public double predictedPrice;
public double confidenceIntervalLower;
public double confidenceIntervalUpper;
public Map<String, Double> components; // ε_bid, ε_time, ε_comp
}
public static class BiddingStrategy {
public String competitionLevel; // LOW, MEDIUM, HIGH, EXTREME
public double maxBid;
public String recommendedTiming; // FINAL_10_MINUTES, FINAL_30_SECONDS, etc.
public String recommendedTimingText;
public String analysis;
public List<String> riskFactors;
}
// Helper class for internal comparable representation
private static class ComparableLot {
String lotId;
double finalPrice;
double conditionScore;
int manufacturingYear;
int hasProvenance;
int daysAgo;
public ComparableLot(String lotId, double finalPrice, double conditionScore, int manufacturingYear, int hasProvenance, int daysAgo) {
this.lotId = lotId;
this.finalPrice = finalPrice;
this.conditionScore = conditionScore;
this.manufacturingYear = manufacturingYear;
this.hasProvenance = hasProvenance;
this.daysAgo = daysAgo;
}
}
}

View File

@@ -0,0 +1,433 @@
package auctiora;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.sql.SQLException;
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.
*/
@Slf4j
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 {
log.info("🔧 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);
log.info("✓ Workflow Orchestrator initialized");
}
/**
* Starts all scheduled workflows.
* This is the main entry point for automated operation.
*/
public void startScheduledWorkflows() {
if (isRunning) {
log.info("⚠️ Workflows already running");
return;
}
log.info("\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;
log.info("✓ 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 {
log.info("📥 [WORKFLOW 1] Importing scraper data...");
var start = System.currentTimeMillis();
// Import auctions
var auctions = db.importAuctionsFromScraper();
log.info(" → Imported {} auctions", auctions.size());
// Import lots
var lots = db.importLotsFromScraper();
log.info(" → Imported {} lots", lots.size());
// Check for images needing detection
var images = db.getImagesNeedingDetection();
log.info(" → Found {} images needing detection", images.size());
var duration = System.currentTimeMillis() - start;
log.info(" ✓ Scraper import completed in {}ms\n", duration);
// 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) {
log.info(" ❌ Scraper import failed: {}", e.getMessage());
}
}, 0, 30, TimeUnit.MINUTES);
log.info(" ✓ Scheduled: Scraper Data Import (every 30 min)");
}
/**
* Workflow 2: Process Pending Images
* Frequency: Every 1 hour
* Purpose: Run object detection on images already downloaded by scraper
*/
private void scheduleImageProcessing() {
scheduler.scheduleAtFixedRate(() -> {
try {
log.info("🖼️ [WORKFLOW 2] Processing pending images...");
var start = System.currentTimeMillis();
// Get images that have been downloaded but need object detection
var pendingImages = db.getImagesNeedingDetection();
if (pendingImages.isEmpty()) {
log.info(" → No pending images to process\n");
return;
}
log.info(" → Processing {} images", pendingImages.size());
var processed = 0;
var detected = 0;
for (var image : pendingImages) {
try {
// Run object detection on already-downloaded image
if (imageProcessor.processImage(image.id(), image.filePath(), image.lotId())) {
processed++;
// Check if objects were detected
var labels = db.getImageLabels(image.id());
if (labels != null && !labels.isEmpty()) {
detected++;
// Send notification for interesting detections
if (labels.size() >= 3) {
notifier.sendNotification(
String.format("Lot %d: Detected %s",
image.lotId(),
String.join(", ", labels)),
"Objects Detected",
0
);
}
}
}
// Rate limiting (lighter since no network I/O)
Thread.sleep(100);
} catch (Exception e) {
log.info("\uFE0F Failed to process image: {}", e.getMessage());
}
}
var duration = System.currentTimeMillis() - start;
log.info(String.format(" ✓ Processed %d images, detected objects in %d (%.1fs)\n",
processed, detected, duration / 1000.0));
} catch (Exception e) {
log.info(" ❌ Image processing failed: {}", e.getMessage());
}
}, 5, 60, TimeUnit.MINUTES);
log.info(" ✓ 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 {
log.info("💰 [WORKFLOW 3] Monitoring bids...");
var start = System.currentTimeMillis();
var activeLots = db.getActiveLots();
log.info(" → Checking {} active lots", activeLots.size());
var 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
}
var duration = System.currentTimeMillis() - start;
log.info(String.format(" ✓ Bid monitoring completed in %dms\n", duration));
} catch (Exception e) {
log.info(" ❌ Bid monitoring failed: {}", e.getMessage());
}
}, 2, 15, TimeUnit.MINUTES);
log.info(" ✓ 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 {
log.info("⏰ [WORKFLOW 4] Checking closing times...");
var start = System.currentTimeMillis();
var activeLots = db.getActiveLots();
var alertsSent = 0;
for (var lot : activeLots) {
if (lot.closingTime() == null) continue;
var minutesLeft = lot.minutesUntilClose();
// Alert for lots closing in 5 minutes
if (minutesLeft <= 5 && minutesLeft > 0 && !lot.closingNotified()) {
var message = String.format("Kavel %d sluit binnen %d min.",
lot.lotId(), minutesLeft);
notifier.sendNotification(message, "Lot Closing Soon", 1);
// Mark as notified
var updated = Lot.basic(
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++;
}
}
var duration = System.currentTimeMillis() - start;
log.info(String.format(" → Sent %d closing alerts in %dms\n",
alertsSent, duration));
} catch (Exception e) {
log.info(" ❌ Closing alerts failed: {}", e.getMessage());
}
}, 1, 5, TimeUnit.MINUTES);
log.info(" ✓ Scheduled: Closing Alerts (every 5 min)");
}
/**
* Manual trigger: Run complete workflow once
* Useful for testing or on-demand execution
*/
public void runCompleteWorkflowOnce() {
log.info("\n🔄 Running Complete Workflow (Manual Trigger)...\n");
try {
// Step 1: Import data
log.info("[1/4] Importing scraper data...");
var auctions = db.importAuctionsFromScraper();
var lots = db.importLotsFromScraper();
log.info(" ✓ Imported {} auctions, {} lots", auctions.size(), lots.size());
// Step 2: Process images
log.info("[2/4] Processing pending images...");
monitor.processPendingImages();
log.info(" ✓ Image processing completed");
// Step 3: Check bids
log.info("[3/4] Monitoring bids...");
var activeLots = db.getActiveLots();
log.info(" ✓ Monitored {} lots", activeLots.size());
// Step 4: Check closing times
log.info("[4/4] Checking closing times...");
var closingSoon = 0;
for (var lot : activeLots) {
if (lot.closingTime() != null && lot.minutesUntilClose() < 30) {
closingSoon++;
}
}
log.info(" ✓ Found {} lots closing soon", closingSoon);
log.info("\n✓ Complete workflow finished successfully\n");
} catch (Exception e) {
log.info("\n❌ Workflow failed: {}\n", e.getMessage());
}
}
/**
* Event-driven trigger: New auction discovered
*/
public void onNewAuctionDiscovered(AuctionInfo auction) {
log.info("\uD83D\uDCE3 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) {
log.info(" ❌ Failed to handle new auction: {}", e.getMessage());
}
}
/**
* Event-driven trigger: Bid change detected
*/
public void onBidChange(Lot lot, double previousBid, double newBid) {
log.info(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) {
log.info(" ❌ Failed to handle bid change: {}", e.getMessage());
}
}
/**
* Event-driven trigger: Objects detected in image
*/
public void onObjectsDetected(int lotId, List<String> labels) {
log.info(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) {
log.info(" ❌ Failed to send detection notification: {}", e.getMessage());
}
}
/**
* Prints current workflow status
*/
public void printStatus() {
log.info("\n📊 Workflow Status:");
log.info(" Running: {}", isRunning ? "Yes" : "No");
try {
var auctions = db.getAllAuctions();
var lots = db.getAllLots();
var images = db.getImageCount();
log.info(" Auctions: {}", auctions.size());
log.info(" Lots: {}", lots.size());
log.info(" Images: {}", images);
// Count closing soon
var closingSoon = 0;
for (var lot : lots) {
if (lot.closingTime() != null && lot.minutesUntilClose() < 30) {
closingSoon++;
}
}
log.info(" Closing soon (< 30 min): {}", closingSoon);
} catch (Exception e) {
log.info("\uFE0F Could not retrieve status: {}", e.getMessage());
}
IO.println();
}
/**
* Gracefully shuts down all workflows
*/
public void shutdown() {
log.info("\n🛑 Shutting down workflows...");
isRunning = false;
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
log.info("✓ Workflows shut down successfully\n");
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}

View File

@@ -0,0 +1,159 @@
package auctiora.db;
import auctiora.AuctionInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.core.Jdbi;
import java.time.LocalDateTime;
import java.util.List;
import auctiora.ScraperDataAdapter;
/**
* Repository for auction-related database operations using JDBI3.
* Handles CRUD operations and queries for auctions.
*/
@Slf4j
@RequiredArgsConstructor
public class AuctionRepository {
private final Jdbi jdbi;
/**
* Inserts or updates an auction record.
* Handles both auction_id conflicts and url uniqueness constraints.
*/
public void upsert(AuctionInfo auction) {
jdbi.useTransaction(handle -> {
try {
// Try INSERT with ON CONFLICT on auction_id
handle.createUpdate("""
INSERT INTO auctions (
auction_id, title, location, city, country, url, type, lot_count, closing_time, discovered_at
) VALUES (
:auctionId, :title, :location, :city, :country, :url, :type, :lotCount, :closingTime, :discoveredAt
)
ON CONFLICT(auction_id) DO UPDATE SET
title = excluded.title,
location = excluded.location,
city = excluded.city,
country = excluded.country,
url = excluded.url,
type = excluded.type,
lot_count = excluded.lot_count,
closing_time = excluded.closing_time
""")
.bind("auctionId", auction.auctionId())
.bind("title", auction.title())
.bind("location", auction.location())
.bind("city", auction.city())
.bind("country", auction.country())
.bind("url", auction.url())
.bind("type", auction.typePrefix())
.bind("lotCount", auction.lotCount())
.bind("closingTime", auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null)
.bind("discoveredAt", java.time.Instant.now().getEpochSecond())
.execute();
} catch (Exception e) {
// If UNIQUE constraint on url fails, try updating by url
var errMsg = e.getMessage();
if (errMsg != null && (errMsg.contains("UNIQUE constraint failed") ||
errMsg.contains("PRIMARY KEY constraint failed"))) {
log.debug("Auction conflict detected, attempting update by URL: {}", auction.url());
var updated = handle.createUpdate("""
UPDATE auctions SET
auction_id = :auctionId,
title = :title,
location = :location,
city = :city,
country = :country,
type = :type,
lot_count = :lotCount,
closing_time = :closingTime
WHERE url = :url
""")
.bind("auctionId", auction.auctionId())
.bind("title", auction.title())
.bind("location", auction.location())
.bind("city", auction.city())
.bind("country", auction.country())
.bind("type", auction.typePrefix())
.bind("lotCount", auction.lotCount())
.bind("closingTime", auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null)
.bind("url", auction.url())
.execute();
if (updated == 0) {
log.warn("Failed to update auction by URL: {}", auction.url());
}
} else {
log.error("Unexpected error upserting auction: {}", e.getMessage(), e);
throw e;
}
}
});
}
/**
* Retrieves all auctions from the database.
*/
public List<AuctionInfo> getAll() {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT * FROM auctions")
.map((rs, ctx) -> {
var closingStr = rs.getString("closing_time");
LocalDateTime closingTime = auctiora.ScraperDataAdapter.parseTimestamp(closingStr);
return new AuctionInfo(
rs.getLong("auction_id"),
rs.getString("title"),
rs.getString("location"),
rs.getString("city"),
rs.getString("country"),
rs.getString("url"),
rs.getString("type"),
rs.getInt("lot_count"),
closingTime
);
})
.list()
);
}
/**
* Retrieves auctions filtered by country code.
*/
public List<AuctionInfo> getByCountry(String countryCode) {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT * FROM auctions WHERE country = :country")
.bind("country", countryCode)
.map((rs, ctx) -> {
var closingStr = rs.getString("closing_time");
LocalDateTime closingTime = null;
if (closingStr != null && !closingStr.isBlank()) {
try {
closingTime = LocalDateTime.parse(closingStr);
} catch (Exception e) {
log.warn("Invalid closing_time format: {}", closingStr);
}
}
return new AuctionInfo(
rs.getLong("auction_id"),
rs.getString("title"),
rs.getString("location"),
rs.getString("city"),
rs.getString("country"),
rs.getString("url"),
rs.getString("type"),
rs.getInt("lot_count"),
closingTime
);
})
.list()
);
}
}

View File

@@ -0,0 +1,154 @@
package auctiora.db;
import lombok.experimental.UtilityClass;
import org.jdbi.v3.core.Jdbi;
/**
* Database schema DDL definitions for all tables and indexes.
* Uses text blocks (Java 15+) for clean SQL formatting.
*/
@UtilityClass
public class DatabaseSchema {
/**
* Initializes all database tables and indexes if they don't exist.
*/
public void ensureSchema(Jdbi jdbi) {
jdbi.useHandle(handle -> {
// Enable WAL mode for better concurrent access
handle.execute("PRAGMA journal_mode=WAL");
handle.execute("PRAGMA busy_timeout=10000");
handle.execute("PRAGMA synchronous=NORMAL");
createTables(handle);
createIndexes(handle);
});
}
private void createTables(org.jdbi.v3.core.Handle handle) {
// Cache table (for HTTP caching)
handle.execute("""
CREATE TABLE IF NOT EXISTS cache (
url TEXT PRIMARY KEY,
content BLOB,
timestamp REAL,
status_code INTEGER
)""");
// Auctions table (populated by external scraper)
handle.execute("""
CREATE TABLE IF NOT EXISTS auctions (
auction_id TEXT PRIMARY KEY,
url TEXT UNIQUE,
title TEXT,
location TEXT,
lots_count INTEGER,
first_lot_closing_time TEXT,
scraped_at TEXT,
city TEXT,
country TEXT,
type TEXT,
lot_count INTEGER DEFAULT 0,
closing_time TEXT,
discovered_at INTEGER
)""");
// Lots table (populated by external scraper)
handle.execute("""
CREATE TABLE IF NOT EXISTS lots (
lot_id TEXT PRIMARY KEY,
auction_id TEXT,
url TEXT UNIQUE,
title TEXT,
current_bid TEXT,
bid_count INTEGER,
closing_time TEXT,
viewing_time TEXT,
pickup_date TEXT,
location TEXT,
description TEXT,
category TEXT,
scraped_at TEXT,
sale_id INTEGER,
manufacturer TEXT,
type TEXT,
year INTEGER,
currency TEXT DEFAULT 'EUR',
closing_notified INTEGER DEFAULT 0,
starting_bid TEXT,
minimum_bid TEXT,
status TEXT,
brand TEXT,
model TEXT,
attributes_json TEXT,
first_bid_time TEXT,
last_bid_time TEXT,
bid_velocity REAL,
bid_increment REAL,
year_manufactured INTEGER,
condition_score REAL,
condition_description TEXT,
serial_number TEXT,
damage_description TEXT,
followers_count INTEGER DEFAULT 0,
estimated_min_price REAL,
estimated_max_price REAL,
lot_condition TEXT,
appearance TEXT,
estimated_min REAL,
estimated_max REAL,
next_bid_step_cents INTEGER,
condition TEXT,
category_path TEXT,
city_location TEXT,
country_code TEXT,
bidding_status TEXT,
packaging TEXT,
quantity INTEGER,
vat REAL,
buyer_premium_percentage REAL,
remarks TEXT,
reserve_price REAL,
reserve_met INTEGER,
view_count INTEGER,
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id)
)""");
// Images table (populated by external scraper with URLs and local_path)
handle.execute("""
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT,
url TEXT,
local_path TEXT,
downloaded INTEGER DEFAULT 0,
labels TEXT,
processed_at INTEGER,
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
)""");
// Bid history table
handle.execute("""
CREATE TABLE IF NOT EXISTS bid_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT NOT NULL,
bid_amount REAL NOT NULL,
bid_time TEXT NOT NULL,
is_autobid INTEGER DEFAULT 0,
bidder_id TEXT,
bidder_number INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
)""");
}
private void createIndexes(org.jdbi.v3.core.Handle handle) {
handle.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
handle.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
handle.execute("CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id)");
handle.execute("CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id)");
handle.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_lot_url ON images(lot_id, url)");
handle.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_lot_time ON bid_history(lot_id, bid_time)");
handle.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
}
}

View File

@@ -0,0 +1,137 @@
package auctiora.db;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.core.Jdbi;
import java.time.Instant;
import java.util.List;
/**
* Repository for image-related database operations using JDBI3.
* Handles image storage, object detection labels, and processing status.
*/
@Slf4j
@RequiredArgsConstructor
public class ImageRepository {
private final Jdbi jdbi;
/**
* Image record containing all image metadata.
*/
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) {}
/**
* Minimal record for images needing object detection processing.
*/
public record ImageDetectionRecord(int id, long lotId, String filePath) {}
/**
* Inserts a complete image record (for testing/legacy compatibility).
* In production, scraper inserts with local_path, monitor updates labels via updateLabels.
*/
public void insert(long lotId, String url, String filePath, List<String> labels) {
jdbi.useHandle(handle ->
handle.createUpdate("""
INSERT INTO images (lot_id, url, local_path, labels, processed_at, downloaded)
VALUES (:lotId, :url, :localPath, :labels, :processedAt, 1)
""")
.bind("lotId", lotId)
.bind("url", url)
.bind("localPath", filePath)
.bind("labels", String.join(",", labels))
.bind("processedAt", Instant.now().getEpochSecond())
.execute()
);
}
/**
* Updates the labels field for an image after object detection.
*/
public void updateLabels(int imageId, List<String> labels) {
jdbi.useHandle(handle ->
handle.createUpdate("UPDATE images SET labels = :labels, processed_at = :processedAt WHERE id = :id")
.bind("labels", String.join(",", labels))
.bind("processedAt", Instant.now().getEpochSecond())
.bind("id", imageId)
.execute()
);
}
/**
* Gets the labels for a specific image.
*/
public List<String> getLabels(int imageId) {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT labels FROM images WHERE id = :id")
.bind("id", imageId)
.mapTo(String.class)
.findOne()
.map(labelsStr -> {
if (labelsStr != null && !labelsStr.isEmpty()) {
return List.of(labelsStr.split(","));
}
return List.<String>of();
})
.orElse(List.of())
);
}
/**
* Retrieves images for a specific lot.
*/
public List<ImageRecord> getImagesForLot(long lotId) {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT id, lot_id, url, local_path, labels FROM images WHERE lot_id = :lotId")
.bind("lotId", lotId)
.map((rs, ctx) -> new ImageRecord(
rs.getInt("id"),
rs.getLong("lot_id"),
rs.getString("url"),
rs.getString("local_path"),
rs.getString("labels")
))
.list()
);
}
/**
* Gets images that have been downloaded by the scraper but need object detection.
* Only returns images that have local_path set but no labels yet.
*/
public List<ImageDetectionRecord> getImagesNeedingDetection() {
return jdbi.withHandle(handle ->
handle.createQuery("""
SELECT i.id, i.lot_id, i.local_path
FROM images i
WHERE i.local_path IS NOT NULL
AND i.local_path != ''
AND (i.labels IS NULL OR i.labels = '')
""")
.map((rs, ctx) -> {
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
String lotIdStr = rs.getString("lot_id");
long lotId = auctiora.ScraperDataAdapter.extractNumericId(lotIdStr);
return new ImageDetectionRecord(
rs.getInt("id"),
lotId,
rs.getString("local_path")
);
})
.list()
);
}
/**
* Gets the total number of images in the database.
*/
public int getImageCount() {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT COUNT(*) FROM images")
.mapTo(Integer.class)
.one()
);
}
}

View File

@@ -0,0 +1,275 @@
package auctiora.db;
import auctiora.Lot;
import auctiora.BidHistory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.core.Jdbi;
import java.time.LocalDateTime;
import java.util.List;
import static java.sql.Types.*;
/**
* Repository for lot-related database operations using JDBI3.
* Handles CRUD operations and queries for auction lots.
*/
@Slf4j
@RequiredArgsConstructor
public class LotRepository {
private final Jdbi jdbi;
/**
* Inserts or updates a lot (upsert operation).
* First tries UPDATE, then falls back to INSERT if lot doesn't exist.
*/
public void upsert(Lot lot) {
jdbi.useTransaction(handle -> {
// Try UPDATE first
int updated = handle.createUpdate("""
UPDATE lots SET
sale_id = :saleId,
auction_id = :auctionId,
title = :title,
description = :description,
manufacturer = :manufacturer,
type = :type,
year = :year,
category = :category,
current_bid = :currentBid,
currency = :currency,
url = :url,
closing_time = :closingTime
WHERE lot_id = :lotId
""")
.bind("saleId", String.valueOf(lot.saleId()))
.bind("auctionId", String.valueOf(lot.saleId())) // auction_id = sale_id
.bind("title", lot.title())
.bind("description", lot.description())
.bind("manufacturer", lot.manufacturer())
.bind("type", lot.type())
.bind("year", lot.year())
.bind("category", lot.category())
.bind("currentBid", lot.currentBid())
.bind("currency", lot.currency())
.bind("url", lot.url())
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
.bind("lotId", String.valueOf(lot.lotId()))
.execute();
if (updated == 0) {
// No rows updated, perform INSERT
handle.createUpdate("""
INSERT OR IGNORE INTO lots (
lot_id, sale_id, auction_id, title, description, manufacturer, type, year,
category, current_bid, currency, url, closing_time, closing_notified
) VALUES (
:lotId, :saleId, :auctionId, :title, :description, :manufacturer, :type, :year,
:category, :currentBid, :currency, :url, :closingTime, :closingNotified
)
""")
.bind("lotId", String.valueOf(lot.lotId()))
.bind("saleId", String.valueOf(lot.saleId()))
.bind("auctionId", String.valueOf(lot.saleId())) // auction_id = sale_id
.bind("title", lot.title())
.bind("description", lot.description())
.bind("manufacturer", lot.manufacturer())
.bind("type", lot.type())
.bind("year", lot.year())
.bind("category", lot.category())
.bind("currentBid", lot.currentBid())
.bind("currency", lot.currency())
.bind("url", lot.url())
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
.bind("closingNotified", lot.closingNotified() ? 1 : 0)
.execute();
}
});
}
/**
* Updates a lot with full intelligence data from GraphQL enrichment.
* Includes all 24+ intelligence fields from bidding platform.
*/
public void upsertWithIntelligence(Lot lot) {
jdbi.useHandle(handle -> {
var update = handle.createUpdate("""
UPDATE lots SET
sale_id = :saleId,
title = :title,
description = :description,
manufacturer = :manufacturer,
type = :type,
year = :year,
category = :category,
current_bid = :currentBid,
currency = :currency,
url = :url,
closing_time = :closingTime,
followers_count = :followersCount,
estimated_min = :estimatedMin,
estimated_max = :estimatedMax,
next_bid_step_cents = :nextBidStepInCents,
condition = :condition,
category_path = :categoryPath,
city_location = :cityLocation,
country_code = :countryCode,
bidding_status = :biddingStatus,
appearance = :appearance,
packaging = :packaging,
quantity = :quantity,
vat = :vat,
buyer_premium_percentage = :buyerPremiumPercentage,
remarks = :remarks,
starting_bid = :startingBid,
reserve_price = :reservePrice,
reserve_met = :reserveMet,
bid_increment = :bidIncrement,
view_count = :viewCount,
first_bid_time = :firstBidTime,
last_bid_time = :lastBidTime,
bid_velocity = :bidVelocity
WHERE lot_id = :lotId
""")
.bind("saleId", lot.saleId())
.bind("title", lot.title())
.bind("description", lot.description())
.bind("manufacturer", lot.manufacturer())
.bind("type", lot.type())
.bind("year", lot.year())
.bind("category", lot.category())
.bind("currentBid", lot.currentBid())
.bind("currency", lot.currency())
.bind("url", lot.url())
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
.bind("followersCount", lot.followersCount())
.bind("estimatedMin", lot.estimatedMin())
.bind("estimatedMax", lot.estimatedMax())
.bind("nextBidStepInCents", lot.nextBidStepInCents())
.bind("condition", lot.condition())
.bind("categoryPath", lot.categoryPath())
.bind("cityLocation", lot.cityLocation())
.bind("countryCode", lot.countryCode())
.bind("biddingStatus", lot.biddingStatus())
.bind("appearance", lot.appearance())
.bind("packaging", lot.packaging())
.bind("quantity", lot.quantity())
.bind("vat", lot.vat())
.bind("buyerPremiumPercentage", lot.buyerPremiumPercentage())
.bind("remarks", lot.remarks())
.bind("startingBid", lot.startingBid())
.bind("reservePrice", lot.reservePrice())
.bind("reserveMet", lot.reserveMet() != null && lot.reserveMet() ? 1 : null)
.bind("bidIncrement", lot.bidIncrement())
.bind("viewCount", lot.viewCount())
.bind("firstBidTime", lot.firstBidTime() != null ? lot.firstBidTime().toString() : null)
.bind("lastBidTime", lot.lastBidTime() != null ? lot.lastBidTime().toString() : null)
.bind("bidVelocity", lot.bidVelocity())
.bind("lotId", lot.lotId());
int updated = update.execute();
if (updated == 0) {
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
}
});
}
/**
* Updates only the current bid for a lot (lightweight update).
*/
public void updateCurrentBid(Lot lot) {
jdbi.useHandle(handle ->
handle.createUpdate("UPDATE lots SET current_bid = :bid WHERE lot_id = :lotId")
.bind("bid", lot.currentBid())
.bind("lotId", String.valueOf(lot.lotId()))
.execute()
);
}
/**
* Updates notification flags for a lot.
*/
public void updateNotificationFlags(Lot lot) {
jdbi.useHandle(handle ->
handle.createUpdate("UPDATE lots SET closing_notified = :notified WHERE lot_id = :lotId")
.bind("notified", lot.closingNotified() ? 1 : 0)
.bind("lotId", String.valueOf(lot.lotId()))
.execute()
);
}
/**
* Retrieves all active lots.
* Note: Despite the name, this returns ALL lots (legacy behavior for backward compatibility).
*/
public List<Lot> getActiveLots() {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT * FROM lots")
.map((rs, ctx) -> auctiora.ScraperDataAdapter.fromScraperLot(rs))
.list()
);
}
/**
* Retrieves all lots from the database.
*/
public List<Lot> getAllLots() {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT * FROM lots")
.map((rs, ctx) -> auctiora.ScraperDataAdapter.fromScraperLot(rs))
.list()
);
}
/**
* Retrieves bid history for a specific lot.
*/
public List<BidHistory> getBidHistory(String lotId) {
return jdbi.withHandle(handle ->
handle.createQuery("""
SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number
FROM bid_history
WHERE lot_id = :lotId
ORDER BY bid_time DESC
""")
.bind("lotId", lotId)
.map((rs, ctx) -> new BidHistory(
rs.getInt("id"),
rs.getString("lot_id"),
rs.getDouble("bid_amount"),
LocalDateTime.parse(rs.getString("bid_time")),
rs.getInt("is_autobid") != 0,
rs.getString("bidder_id"),
(Integer) rs.getObject("bidder_number")
))
.list()
);
}
/**
* Inserts bid history records in batch.
*/
public void insertBidHistory(List<BidHistory> bidHistory) {
jdbi.useHandle(handle -> {
var batch = handle.prepareBatch("""
INSERT OR IGNORE INTO bid_history (
lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number
) VALUES (:lotId, :bidAmount, :bidTime, :isAutobid, :bidderId, :bidderNumber)
""");
bidHistory.forEach(bid ->
batch.bind("lotId", bid.lotId())
.bind("bidAmount", bid.bidAmount())
.bind("bidTime", bid.bidTime().toString())
.bind("isAutobid", bid.isAutobid() ? 1 : 0)
.bind("bidderId", bid.bidderId())
.bind("bidderNumber", bid.bidderNumber())
.add()
);
batch.execute();
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#2563eb" rx="15"/>
<path d="M25 40 L50 20 L75 40 L75 70 L25 70 Z" fill="#ffffff" stroke="#ffffff" stroke-width="2"/>
<circle cx="50" cy="45" r="8" fill="#2563eb"/>
<rect x="40" y="55" width="20" height="3" fill="#2563eb"/>
<text x="50" y="90" font-family="Arial" font-size="12" fill="#ffffff" text-anchor="middle" font-weight="bold">AUCTION</text>
</svg>

After

Width:  |  Height:  |  Size: 465 B

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrape-UI 1 - Enterprise</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="gradient-bg text-white py-8">
<div class="container mx-auto px-4">
<h1 class="text-4xl font-bold mb-2">Scrape-UI Enterprise</h1>
<p class="text-xl opacity-90">Powered by Quarkus + Modern Frontend</p>
</div>
</header>
<!-- Main Content -->
<main class="container mx-auto px-4 py-8">
<!-- API Status Card -->
<!-- API & Build Status Card -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8 card-hover">
<h2 class="text-2xl font-bold mb-4 text-gray-800">Build & Runtime Status</h2>
<div id="api-status" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Build Information -->
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">📦 Maven Build</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Group:</span>
<span class="font-mono font-medium" id="build-group">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Artifact:</span>
<span class="font-mono font-medium" id="build-artifact">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Version:</span>
<span class="font-mono font-medium px-2 py-1 bg-blue-100 rounded" id="build-version">-</span>
</div>
</div>
</div>
<!-- Runtime Information -->
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="font-semibold text-green-800 mb-2">🚀 Runtime</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Status:</span>
<span class="px-2 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800" id="runtime-status">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Java:</span>
<span class="font-mono" id="java-version">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Platform:</span>
<span class="font-mono" id="runtime-os">-</span>
</div>
</div>
</div>
</div>
<!-- Timestamp & Additional Info -->
<div class="pt-4 border-t">
<div class="flex justify-between items-center">
<div>
<p class="text-sm text-gray-500">Last Updated</p>
<p class="font-medium" id="last-updated">-</p>
</div>
<button onclick="fetchStatus()" class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm transition-colors">
🔄 Refresh
</button>
</div>
</div>
</div>
</div>
<!-- API Response Card -->
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
<h2 class="text-2xl font-bold mb-4 text-gray-800">API Test</h2>
<button id="test-api" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors mb-4">
Test Greeting API
</button>
<div id="api-response" class="bg-gray-100 p-4 rounded-lg">
<pre class="text-sm text-gray-700">Click the button to test the API</pre>
</div>
</div>
<!-- Features Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
<h3 class="text-xl font-semibold mb-2 text-gray-800">⚡ Quarkus Backend</h3>
<p class="text-gray-600">Fast startup, low memory footprint, optimized for containers</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
<h3 class="text-xl font-semibold mb-2 text-gray-800">🚀 REST API</h3>
<p class="text-gray-600">RESTful endpoints with JSON responses</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
<h3 class="text-xl font-semibold mb-2 text-gray-800">🎨 Modern UI</h3>
<p class="text-gray-600">Responsive design with Tailwind CSS</p>
</div>
</div>
</main>
<script>
// Fetch API status on load
async function fetchStatus() {
try {
const response = await fetch('/api/status')
if (!response.ok) {
throw new Error(`HTTP ${ response.status }: ${ response.statusText }`)
}
const data = await response.json()
// Update Build Information
document.getElementById('build-group').textContent = data.groupId || 'N/A'
document.getElementById('build-artifact').textContent = data.artifactId || data.name || 'N/A'
document.getElementById('build-version').textContent = data.version || 'N/A'
// Update Runtime Information
document.getElementById('runtime-status').textContent = data.status || 'unknown'
document.getElementById('java-version').textContent = data.javaVersion || System.getProperty?.('java.version') || 'N/A'
document.getElementById('runtime-os').textContent = data.os || 'N/A'
// Update Timestamp
const timestamp = data.timestamp ? new Date(data.timestamp).toLocaleString() : 'N/A'
document.getElementById('last-updated').textContent = timestamp
// Update status badge color based on status
const statusBadge = document.getElementById('runtime-status')
if (data.status?.toLowerCase() === 'running') {
statusBadge.className = 'px-2 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800'
} else {
statusBadge.className = 'px-2 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800'
}
} catch (error) {
console.error('Error fetching status:', error)
document.getElementById('api-status').innerHTML = `
<div class="bg-red-50 border-l-4 border-red-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">Failed to load status: ${ error.message }</p>
<button onclick="fetchStatus()" class="mt-2 text-sm text-red-700 hover:text-red-600 font-medium">
Retry →
</button>
</div>
</div>
</div>
`
}
}
// Fetch API status on load
async function fetchStatus3() {
try {
const response = await fetch('/api/status')
const data = await response.json()
document.getElementById('api-status').innerHTML = `
<p><strong>Application:</strong> ${ data.application }</p>
<p><strong>Status:</strong> <span class="text-green-600 font-semibold">${ data.status }</span></p>
<p><strong>Version:</strong> ${ data.version }</p>
<p><strong>Timestamp:</strong> ${ data.timestamp }</p>
`
} catch (error) {
document.getElementById('api-status').innerHTML = `
<p class="text-red-600">Error loading status: ${ error.message }</p>
`
}
}
// Test greeting API
document.getElementById('test-api').addEventListener('click', async () => {
try {
const response = await fetch('/api/hello')
const data = await response.json()
document.getElementById('api-response').innerHTML = `
<pre class="text-sm text-gray-700">${ JSON.stringify(data, null, 2) }</pre>
`
} catch (error) {
document.getElementById('api-response').innerHTML = `
<pre class="text-sm text-red-600">Error: ${ error.message }</pre>
`
}
})
// Auto-refresh every 30 seconds
let refreshInterval = setInterval(fetchStatus, 30000);
// Stop auto-refresh when page loses focus (optional)
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
clearInterval(refreshInterval);
} else {
refreshInterval = setInterval(fetchStatus, 30000);
fetchStatus(); // Refresh immediately when returning to tab
}
});
// Load status on page load
fetchStatus()
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
# Application Configuration
# Values will be injected from pom.xml during build
quarkus.application.name=${project.artifactId}
quarkus.application.version=${project.version}
# Custom properties for groupId if needed
application.groupId=${project.groupId}
application.artifactId=${project.artifactId}
application.version=${project.version}
# HTTP Configuration
quarkus.http.port=8081
# ========== DEVELOPMENT (quarkus:dev) ==========
%dev.quarkus.http.host=127.0.0.1
# ========== PRODUCTION (Docker/JAR) ==========
%prod.quarkus.http.host=0.0.0.0
# ========== TEST PROFILE ==========
%test.quarkus.http.host=localhost
# 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
# JVM Arguments for native access (Jansi, OpenCV, etc.)
quarkus.native.additional-build-args=--enable-native-access=ALL-UNNAMED
# Production optimizations
%prod.quarkus.package.type=fast-jar
%prod.quarkus.http.enable-compression=true
# Static resources
quarkus.http.enable-compression=true
quarkus.rest.path=/
quarkus.http.root-path=/
# Auction Monitor Configuration
auction.database.path=/mnt/okcomputer/output/cache.db
auction.images.path=/mnt/okcomputer/output/images
# auction.notification.config=desktop
# Format: smtp:username:password:recipient_email
auction.notification.config=smtp:michael.bakker1986@gmail.com:agrepolhlnvhipkv:michael.bakker1986@gmail.com
auction.yolo.config=/mnt/okcomputer/output/models/yolov4.cfg
auction.yolo.weights=/mnt/okcomputer/output/models/yolov4.weights
auction.yolo.classes=/mnt/okcomputer/output/models/coco.names
# Scheduler Configuration
quarkus.scheduler.enabled=true
quarkus.scheduler.start-halted=false
# Workflow Schedules
auction.workflow.scraper-import.cron=0 */30 * * * ?
auction.workflow.image-processing.cron=0 0 * * * ?
auction.workflow.bid-monitoring.cron=0 */15 * * * ?
auction.workflow.closing-alerts.cron=0 */5 * * * ?
# HTTP Rate Limiting Configuration
# Prevents overloading external services and getting blocked
auction.http.rate-limit.default-max-rps=2
auction.http.rate-limit.troostwijk-max-rps=1
auction.http.timeout-seconds=30
# Health Check Configuration
quarkus.smallrye-health.root-path=/health

View File

@@ -0,0 +1,20 @@
# SLF4J Simple Logger Configuration
# Set default log level (trace, debug, info, warn, error, off)
org.slf4j.simpleLogger.defaultLogLevel=warn
# Show date/time in logs
org.slf4j.simpleLogger.showDateTime=true
org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss
# Show thread name
org.slf4j.simpleLogger.showThreadName=false
# Show log name (logger name)
org.slf4j.simpleLogger.showLogName=false
# Show short log name
org.slf4j.simpleLogger.showShortLogName=true
# Set specific logger levels
org.slf4j.simpleLogger.log.com.microsoft.playwright=warn
org.slf4j.simpleLogger.log.org.sqlite=warn

View File

@@ -0,0 +1,138 @@
package auctiora;
import org.junit.jupiter.api.*;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for closing time calculations that power the UI
* Tests the minutesUntilClose() logic used in dashboard and alerts
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("Closing Time Calculation Tests")
class ClosingTimeCalculationTest {
@Test
@Order(1)
@DisplayName("Should calculate minutes until close for lot closing in 15 minutes")
void testMinutesUntilClose15Minutes() {
var lot = createLot(LocalDateTime.now().plusMinutes(15));
long minutes = lot.minutesUntilClose();
assertTrue(minutes >= 14 && minutes <= 16,
"Should be approximately 15 minutes, was: " + minutes);
}
@Test
@Order(2)
@DisplayName("Should calculate minutes until close for lot closing in 2 hours")
void testMinutesUntilClose2Hours() {
var lot = createLot(LocalDateTime.now().plusHours(2));
long minutes = lot.minutesUntilClose();
assertTrue(minutes >= 119 && minutes <= 121,
"Should be approximately 120 minutes, was: " + minutes);
}
@Test
@Order(3)
@DisplayName("Should return negative value for already closed lot")
void testMinutesUntilCloseNegative() {
var lot = createLot(LocalDateTime.now().minusHours(1));
long minutes = lot.minutesUntilClose();
assertTrue(minutes < 0,
"Should be negative for closed lots, was: " + minutes);
}
@Test
@Order(4)
@DisplayName("Should return MAX_VALUE when lot has no closing time")
void testMinutesUntilCloseNoTime() {
var lot = Lot.basic(100, 1001, "No closing time", "", "", "", 0, "General",
100.0, "EUR", "http://test.com/1001", null, false);
long minutes = lot.minutesUntilClose();
assertEquals(Long.MAX_VALUE, minutes,
"Should return MAX_VALUE when no closing time set");
}
@Test
@Order(5)
@DisplayName("Should identify lots closing within 5 minutes (critical threshold)")
void testCriticalClosingThreshold() {
var closing4Min = createLot(LocalDateTime.now().plusMinutes(4));
var closing5Min = createLot(LocalDateTime.now().plusMinutes(5));
var closing6Min = createLot(LocalDateTime.now().plusMinutes(6));
assertTrue(closing4Min.minutesUntilClose() < 5,
"Lot closing in 4 min should be < 5 minutes");
assertTrue(closing5Min.minutesUntilClose() >= 5,
"Lot closing in 5 min should be >= 5 minutes");
assertTrue(closing6Min.minutesUntilClose() > 5,
"Lot closing in 6 min should be > 5 minutes");
}
@Test
@Order(6)
@DisplayName("Should identify lots closing within 30 minutes (dashboard threshold)")
void testDashboardClosingThreshold() {
var closing20Min = createLot(LocalDateTime.now().plusMinutes(20));
var closing31Min = createLot(LocalDateTime.now().plusMinutes(31)); // Use 31 to avoid boundary timing issue
var closing40Min = createLot(LocalDateTime.now().plusMinutes(40));
assertTrue(closing20Min.minutesUntilClose() < 30,
"Lot closing in 20 min should be < 30 minutes");
assertTrue(closing31Min.minutesUntilClose() >= 30,
"Lot closing in 31 min should be >= 30 minutes");
assertTrue(closing40Min.minutesUntilClose() > 30,
"Lot closing in 40 min should be > 30 minutes");
}
@Test
@Order(7)
@DisplayName("Should calculate correctly for lots closing soon (boundary cases)")
void testBoundaryCases() {
// Just closed (< 1 minute ago)
var justClosed = createLot(LocalDateTime.now().minusSeconds(30));
assertTrue(justClosed.minutesUntilClose() <= 0, "Just closed should be <= 0");
// Closing very soon (< 1 minute)
var closingVerySoon = createLot(LocalDateTime.now().plusSeconds(30));
assertTrue(closingVerySoon.minutesUntilClose() < 1, "Closing in 30 sec should be < 1 minute");
// Closing in exactly 1 hour
var closing1Hour = createLot(LocalDateTime.now().plusHours(1));
long minutes1Hour = closing1Hour.minutesUntilClose();
assertTrue(minutes1Hour >= 59 && minutes1Hour <= 61,
"Closing in 1 hour should be ~60 minutes, was: " + minutes1Hour);
}
@Test
@Order(8)
@DisplayName("Multiple lots should sort correctly by urgency")
void testSortingByUrgency() {
var lot5Min = createLot(LocalDateTime.now().plusMinutes(5));
var lot30Min = createLot(LocalDateTime.now().plusMinutes(30));
var lot1Hour = createLot(LocalDateTime.now().plusHours(1));
var lot3Hours = createLot(LocalDateTime.now().plusHours(3));
var lots = java.util.List.of(lot3Hours, lot30Min, lot5Min, lot1Hour);
var sorted = lots.stream()
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
.toList();
assertEquals(lot5Min, sorted.get(0), "Most urgent should be first");
assertEquals(lot30Min, sorted.get(1), "Second most urgent");
assertEquals(lot1Hour, sorted.get(2), "Third most urgent");
assertEquals(lot3Hours, sorted.get(3), "Least urgent should be last");
}
// Helper method
private Lot createLot(LocalDateTime closingTime) {
return Lot.basic(100, 1001, "Test Item", "", "", "", 0, "General",
100.0, "EUR", "http://test.com/1001", closingTime, false);
}
}

View File

@@ -0,0 +1,390 @@
package auctiora;
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.*;
/**
* 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 {
// Load SQLite JDBC driver
try {
Class.forName("org.sqlite.JDBC");
} catch (ClassNotFoundException e) {
throw new SQLException("SQLite JDBC driver not found", e);
}
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 = Lot.basic(
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 = Lot.basic(
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/22222", null, false
);
db.upsertLot(lot);
// Update bid
var updatedLot = Lot.basic(
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 = Lot.basic(
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 = Lot.basic(
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 = Lot.basic(
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 = Lot.basic(
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, IOException {
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 = Lot.basic(
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 = Lot.basic(
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, SQLException {
Thread t1 = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
db.upsertLot(Lot.basic(
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
100.0, "EUR", "https://example.com/" + i, null, false
));
}
} catch (Exception e) {
fail("Thread 1 failed: " + e.getMessage());
}
});
Thread t2 = new Thread(() -> {
try {
for (int i = 10; i < 20; i++) {
db.upsertLot(Lot.basic(
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
200.0, "EUR", "https://example.com/" + i, null, false
));
}
} catch (Exception 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,186 @@
package auctiora;
import org.junit.jupiter.api.*;
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 object detection integration and database label updates.
*
* NOTE: Image downloading is now handled by the scraper, so these tests
* focus only on object detection and label storage.
*/
class ImageProcessingServiceTest {
private DatabaseService mockDb;
private ObjectDetectionService mockDetector;
private ImageProcessingService service;
private java.io.File testImage;
@BeforeEach
void setUp() throws Exception {
mockDb = mock(DatabaseService.class);
mockDetector = mock(ObjectDetectionService.class);
service = new ImageProcessingService(mockDb, mockDetector);
// Create a temporary test image file
testImage = java.io.File.createTempFile("test_image_", ".jpg");
testImage.deleteOnExit();
// Write minimal JPEG header to make it a valid file
try (var out = new java.io.FileOutputStream(testImage)) {
out.write(new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF, (byte)0xE0});
}
}
@Test
@DisplayName("Should process single image and update labels")
void testProcessImage() throws SQLException {
// Normalize path (convert backslashes to forward slashes)
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
// Mock object detection with normalized path
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("car", "vehicle"));
// Process image
boolean result = service.processImage(1, testImage.getAbsolutePath(), 12345);
// Verify success
assertTrue(result);
verify(mockDetector).detectObjects(normalizedPath);
verify(mockDb).updateImageLabels(1, List.of("car", "vehicle"));
}
@Test
@DisplayName("Should handle empty detection results")
void testProcessImageWithNoDetections() throws SQLException {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of());
boolean result = service.processImage(2, testImage.getAbsolutePath(), 12346);
assertTrue(result);
verify(mockDb).updateImageLabels(2, List.of());
}
@Test
@DisplayName("Should handle database error gracefully")
void testProcessImageDatabaseError() {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("object"));
doThrow(new RuntimeException("Database error"))
.when(mockDb).updateImageLabels(anyInt(), anyList());
// Should return false on error
boolean result = service.processImage(3, testImage.getAbsolutePath(), 12347);
assertFalse(result);
}
@Test
@DisplayName("Should handle object detection error gracefully")
void testProcessImageDetectionError() {
when(mockDetector.detectObjects(anyString()))
.thenThrow(new RuntimeException("Detection failed"));
// Should return false on error
boolean result = service.processImage(4, "/path/to/image4.jpg", 12348);
assertFalse(result);
}
@Test
@DisplayName("Should process pending images batch")
void testProcessPendingImages() throws SQLException {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
// Mock pending images from database - use real test image path
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
new DatabaseService.ImageDetectionRecord(1, 100L, testImage.getAbsolutePath()),
new DatabaseService.ImageDetectionRecord(2, 101L, testImage.getAbsolutePath())
));
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("item1"))
.thenReturn(List.of("item2"));
when(mockDb.getImageLabels(anyInt()))
.thenReturn(List.of("item1"))
.thenReturn(List.of("item2"));
// Process batch
service.processPendingImages();
// Verify all images were processed
verify(mockDb).getImagesNeedingDetection();
verify(mockDetector, times(2)).detectObjects(normalizedPath);
verify(mockDb, times(2)).updateImageLabels(anyInt(), anyList());
}
@Test
@DisplayName("Should handle empty pending images list")
void testProcessPendingImagesEmpty() throws SQLException {
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of());
service.processPendingImages();
verify(mockDb).getImagesNeedingDetection();
verify(mockDetector, never()).detectObjects(anyString());
}
@Test
@DisplayName("Should continue processing after single image failure")
void testProcessPendingImagesWithFailure() throws SQLException {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
new DatabaseService.ImageDetectionRecord(1, 100L, testImage.getAbsolutePath()),
new DatabaseService.ImageDetectionRecord(2, 101L, testImage.getAbsolutePath())
));
// First image fails, second succeeds
when(mockDetector.detectObjects(normalizedPath))
.thenThrow(new RuntimeException("Detection error"))
.thenReturn(List.of("item"));
when(mockDb.getImageLabels(2))
.thenReturn(List.of("item"));
service.processPendingImages();
// Verify second image was still processed
verify(mockDetector, times(2)).detectObjects(normalizedPath);
}
@Test
@DisplayName("Should handle database query error in batch processing")
void testProcessPendingImagesDatabaseError() {
when(mockDb.getImagesNeedingDetection())
.thenThrow(new RuntimeException("Database connection failed"));
// Should not throw exception
assertDoesNotThrow(() -> service.processPendingImages());
}
@Test
@DisplayName("Should process images with multiple detected objects")
void testProcessImageMultipleDetections() throws SQLException {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("car", "truck", "vehicle", "road"));
boolean result = service.processImage(5, testImage.getAbsolutePath(), 12349);
assertTrue(result);
verify(mockDb).updateImageLabels(5, List.of("car", "truck", "vehicle", "road"));
}
}

View File

@@ -0,0 +1,461 @@
package auctiora;
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 = Lot.basic(
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 = Lot.basic(
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 = Lot.basic(
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
var 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 = Lot.basic(
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
var message = "Kavel " + closingSoon.lotId() + " sluit binnen 5 min.";
assertDoesNotThrow(() ->
notifier.sendNotification(message, "Lot nearing closure", 1)
);
// Mark as notified
var notified = Lot.basic(
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
var 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 = Lot.basic(
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
var 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, SQLException {
var auctionThread = new Thread(() -> {
try {
for (var 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 (Exception e) {
fail("Auction thread failed: " + e.getMessage());
}
});
var lotThread = new Thread(() -> {
try {
for (var i = 0; i < 10; i++) {
db.upsertLot(Lot.basic(
60000 + i, 70000 + i, "Concurrent Lot " + i, "Desc", "", "", 0, "Cat",
100.0 * i, "EUR", "https://example.com/70" + i, null, false
));
}
} catch (Exception 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();
var auctionCount = auctions.stream()
.filter(a -> a.auctionId() >= 60000 && a.auctionId() < 60010)
.count();
var 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,243 @@
package auctiora;
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() {
var service = new NotificationService("desktop");
assertNotNull(service);
}
@Test
@DisplayName("Should initialize with SMTP configuration")
void testSMTPConfiguration() {
var 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 (only 2 parts total)
assertThrows(IllegalArgumentException.class, () ->
new NotificationService("smtp:incomplete")
);
// Wrong format (only 3 parts total, needs 4)
assertThrows(IllegalArgumentException.class, () ->
new NotificationService("smtp:only:two")
);
}
@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() {
var 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() {
var service = new NotificationService("desktop");
assertDoesNotThrow(() ->
service.sendNotification("Urgent message", "High Priority", 1)
);
}
@Test
@DisplayName("Should send normal priority notification")
void testNormalPriorityNotification() {
var service = new NotificationService("desktop");
assertDoesNotThrow(() ->
service.sendNotification("Regular message", "Normal Priority", 0)
);
}
@Test
@DisplayName("Should handle notification when system tray not supported")
void testNoSystemTraySupport() {
var 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
var 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() {
var 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() {
var service = new NotificationService("desktop");
assertDoesNotThrow(() ->
service.sendNotification("", "", 0)
);
}
@Test
@DisplayName("Should handle very long message")
void testLongMessage() {
var service = new NotificationService("desktop");
var longMessage = "A".repeat(1000);
assertDoesNotThrow(() ->
service.sendNotification(longMessage, "Long Message Test", 0)
);
}
@Test
@DisplayName("Should handle special characters in message")
void testSpecialCharactersInMessage() {
var 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() {
var service = new NotificationService("desktop");
assertDoesNotThrow(() -> {
for (var 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")
);
}
@Test
@DisplayName("Should send bid change notification format")
void testBidChangeNotificationFormat() {
var service = new NotificationService("desktop");
var message = "Nieuw bod op kavel 12345: €150.00 (was €125.00)";
var title = "Kavel bieding update";
assertDoesNotThrow(() ->
service.sendNotification(message, title, 0)
);
}
@Test
@DisplayName("Should send closing alert notification format")
void testClosingAlertNotificationFormat() {
var service = new NotificationService("desktop");
var message = "Kavel 12345 sluit binnen 5 min.";
var title = "Lot nearing closure";
assertDoesNotThrow(() ->
service.sendNotification(message, title, 1)
);
}
@Test
@DisplayName("Should send object detection notification format")
void testObjectDetectionNotificationFormat() {
var service = new NotificationService("desktop");
var message = "Lot contains: car, truck, machinery\nEstimated value: €5000";
var title = "Object Detected";
assertDoesNotThrow(() ->
service.sendNotification(message, title, 0)
);
}
}

View File

@@ -0,0 +1,185 @@
package auctiora;
import org.junit.jupiter.api.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
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 {
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 gracefully handle when model files exist but OpenCV fails to load")
void testInitializeWithValidModels() throws IOException {
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");
// When files exist but OpenCV native library isn't loaded,
// service should construct successfully but be disabled (handled in @PostConstruct)
var service = new ObjectDetectionService(TEST_CFG, TEST_WEIGHTS, TEST_CLASSES);
// Service is created, but init() handles failures gracefully
// detectObjects should return empty list when disabled
assertNotNull(service);
} finally {
Files.deleteIfExists(cfgPath);
Files.deleteIfExists(weightsPath);
Files.deleteIfExists(classesPath);
}
}
@Test
@DisplayName("Should handle missing class names file")
void testMissingClassNamesFile() throws IOException {
// When model files don't exist, service initializes in disabled mode (no exception)
ObjectDetectionService service = new ObjectDetectionService(
"non_existent.cfg",
"non_existent.weights",
"non_existent.txt"
);
assertNotNull(service);
// Verify it returns empty results when disabled
assertTrue(service.detectObjects("test.jpg").isEmpty());
}
@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);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,255 @@
package auctiora;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
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 return 0 for IDs that exceed Long.MAX_VALUE")
void testExtractNumericIdTooLarge() {
// These IDs are too large for a long (> 19 digits or > Long.MAX_VALUE)
assertEquals(0, ScraperDataAdapter.extractNumericId("856462986966260305674"));
assertEquals(0, ScraperDataAdapter.extractNumericId("28492384530402679688"));
assertEquals(0, ScraperDataAdapter.extractNumericId("A7-856462986966260305674"));
}
@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.typePrefix());
assertEquals(150, result.lotCount());
assertNotNull(result.firstLotClosingTime());
}
@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.firstLotClosingTime());
}
@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.typePrefix());
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.typePrefix());
}
@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,380 @@
package auctiora;
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";
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.getDb());
}
@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.getDb().getAllAuctions();
var lots = monitor.getDb().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 = Lot.basic(
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.getDb().upsertLot(lot);
var lots = monitor.getDb().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 = Lot.basic(
33333, 44444,
"Closing Soon Item",
"Description",
"",
"",
0,
"Category",
100.00,
"EUR",
"https://example.com/lot/44444",
LocalDateTime.now().plusMinutes(4),
false
);
monitor.getDb().upsertLot(closingSoon);
var lots = monitor.getDb().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 = Lot.basic(
55555, 66666,
"Future Lot",
"Description",
"",
"",
0,
"Category",
200.00,
"EUR",
"https://example.com/lot/66666",
LocalDateTime.now().plusHours(2),
false
);
monitor.getDb().upsertLot(futureLot);
var lots = monitor.getDb().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 = Lot.basic(
77777, 88888,
"No Closing Time",
"Description",
"",
"",
0,
"Category",
150.00,
"EUR",
"https://example.com/lot/88888",
null,
false
);
monitor.getDb().upsertLot(noClosing);
var lots = monitor.getDb().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 = Lot.basic(
99999, 11110,
"Test Notification",
"Description",
"",
"",
0,
"Category",
100.00,
"EUR",
"https://example.com/lot/11110",
LocalDateTime.now().plusMinutes(3),
false
);
monitor.getDb().upsertLot(lot);
// Update notification flag
var notified = Lot.basic(
99999, 11110,
"Test Notification",
"Description",
"",
"",
0,
"Category",
100.00,
"EUR",
"https://example.com/lot/11110",
LocalDateTime.now().plusMinutes(3),
true
);
monitor.getDb().updateLotNotificationFlags(notified);
var lots = monitor.getDb().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 = Lot.basic(
12121, 13131,
"Bid Update Test",
"Description",
"",
"",
0,
"Category",
100.00,
"EUR",
"https://example.com/lot/13131",
LocalDateTime.now().plusDays(1),
false
);
monitor.getDb().upsertLot(lot);
// Simulate bid increase
var higherBid = Lot.basic(
12121, 13131,
"Bid Update Test",
"Description",
"",
"",
0,
"Category",
250.00,
"EUR",
"https://example.com/lot/13131",
LocalDateTime.now().plusDays(1),
false
);
monitor.getDb().updateLotCurrentBid(higherBid);
var lots = monitor.getDb().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, SQLException {
Thread t1 = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
monitor.getDb().upsertLot(Lot.basic(
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
100.0, "EUR", "https://example.com/" + i, null, false
));
}
} catch (Exception e) {
fail("Thread 1 failed: " + e.getMessage());
}
});
Thread t2 = new Thread(() -> {
try {
for (int i = 5; i < 10; i++) {
monitor.getDb().upsertLot(Lot.basic(
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
200.0, "EUR", "https://example.com/" + i, null, false
));
}
} catch (Exception e) {
fail("Thread 2 failed: " + e.getMessage());
}
});
t1.start();
t2.start();
t1.join();
t2.join();
var lots = monitor.getDb().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.getDb().upsertAuction(auction);
// Insert related lot
var lot = Lot.basic(
40000, 50000,
"Test Lot",
"Description",
"",
"",
0,
"Category",
500.00,
"EUR",
"https://example.com/lot/50000",
LocalDateTime.now().plusDays(2),
false
);
monitor.getDb().upsertLot(lot);
// Verify
var auctions = monitor.getDb().getAllAuctions();
var lots = monitor.getDb().getAllLots();
assertTrue(auctions.stream().anyMatch(a -> a.auctionId() == 40000));
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 50000));
}
}