Initial clean commit
This commit is contained in:
19
src/main/java/auctiora/AuctionInfo.java
Normal file
19
src/main/java/auctiora/AuctionInfo.java
Normal 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
|
||||
) { }
|
||||
82
src/main/java/auctiora/AuctionMonitorHealthCheck.java
Normal file
82
src/main/java/auctiora/AuctionMonitorHealthCheck.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/main/java/auctiora/AuctionMonitorProducer.java
Normal file
61
src/main/java/auctiora/AuctionMonitorProducer.java
Normal 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);
|
||||
}
|
||||
}
|
||||
925
src/main/java/auctiora/AuctionMonitorResource.java
Normal file
925
src/main/java/auctiora/AuctionMonitorResource.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/main/java/auctiora/BidHistory.java
Normal file
16
src/main/java/auctiora/BidHistory.java
Normal 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
|
||||
) {}
|
||||
218
src/main/java/auctiora/DatabaseService.java
Normal file
218
src/main/java/auctiora/DatabaseService.java
Normal 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) {}
|
||||
}
|
||||
55
src/main/java/auctiora/ImageProcessingService.java
Normal file
55
src/main/java/auctiora/ImageProcessingService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src/main/java/auctiora/Lot.java
Normal file
153
src/main/java/auctiora/Lot.java
Normal 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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
81
src/main/java/auctiora/LotEnrichmentScheduler.java
Normal file
81
src/main/java/auctiora/LotEnrichmentScheduler.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/main/java/auctiora/LotEnrichmentService.java
Normal file
201
src/main/java/auctiora/LotEnrichmentService.java
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/main/java/auctiora/LotIntelligence.java
Normal file
33
src/main/java/auctiora/LotIntelligence.java
Normal 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
|
||||
) {}
|
||||
170
src/main/java/auctiora/NotificationService.java
Normal file
170
src/main/java/auctiora/NotificationService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
244
src/main/java/auctiora/ObjectDetectionService.java
Normal file
244
src/main/java/auctiora/ObjectDetectionService.java
Normal 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 pre‑trained 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
|
||||
* human‑readable 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
|
||||
* post‑processing【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);
|
||||
// Post‑process: 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;
|
||||
}
|
||||
}
|
||||
309
src/main/java/auctiora/QuarkusWorkflowScheduler.java
Normal file
309
src/main/java/auctiora/QuarkusWorkflowScheduler.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
246
src/main/java/auctiora/RateLimitedHttpClient.java
Normal file
246
src/main/java/auctiora/RateLimitedHttpClient.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
196
src/main/java/auctiora/ScraperDataAdapter.java
Normal file
196
src/main/java/auctiora/ScraperDataAdapter.java
Normal 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;
|
||||
}
|
||||
}
|
||||
74
src/main/java/auctiora/StatusResource.java
Normal file
74
src/main/java/auctiora/StatusResource.java
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
378
src/main/java/auctiora/TroostwijkGraphQLClient.java
Normal file
378
src/main/java/auctiora/TroostwijkGraphQLClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
132
src/main/java/auctiora/TroostwijkMonitor.java
Normal file
132
src/main/java/auctiora/TroostwijkMonitor.java
Normal 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();
|
||||
}
|
||||
}
|
||||
378
src/main/java/auctiora/ValuationAnalyticsResource.java
Normal file
378
src/main/java/auctiora/ValuationAnalyticsResource.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
433
src/main/java/auctiora/WorkflowOrchestrator.java
Normal file
433
src/main/java/auctiora/WorkflowOrchestrator.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
159
src/main/java/auctiora/db/AuctionRepository.java
Normal file
159
src/main/java/auctiora/db/AuctionRepository.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
154
src/main/java/auctiora/db/DatabaseSchema.java
Normal file
154
src/main/java/auctiora/db/DatabaseSchema.java
Normal 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)");
|
||||
}
|
||||
}
|
||||
137
src/main/java/auctiora/db/ImageRepository.java
Normal file
137
src/main/java/auctiora/db/ImageRepository.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
275
src/main/java/auctiora/db/LotRepository.java
Normal file
275
src/main/java/auctiora/db/LotRepository.java
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
BIN
src/main/resources/META-INF/resources/favicon.ico
Normal file
BIN
src/main/resources/META-INF/resources/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
7
src/main/resources/META-INF/resources/favicon.svg
Normal file
7
src/main/resources/META-INF/resources/favicon.svg
Normal 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 |
1611
src/main/resources/META-INF/resources/index.html
Normal file
1611
src/main/resources/META-INF/resources/index.html
Normal file
File diff suppressed because it is too large
Load Diff
0
src/main/resources/META-INF/resources/script.js
Normal file
0
src/main/resources/META-INF/resources/script.js
Normal file
224
src/main/resources/META-INF/resources/status.html
Normal file
224
src/main/resources/META-INF/resources/status.html
Normal 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>
|
||||
0
src/main/resources/META-INF/resources/style.css
Normal file
0
src/main/resources/META-INF/resources/style.css
Normal file
1086
src/main/resources/META-INF/resources/valuation-analytics.html
Normal file
1086
src/main/resources/META-INF/resources/valuation-analytics.html
Normal file
File diff suppressed because it is too large
Load Diff
75
src/main/resources/application.properties
Normal file
75
src/main/resources/application.properties
Normal 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
|
||||
|
||||
20
src/main/resources/simplelogger.properties
Normal file
20
src/main/resources/simplelogger.properties
Normal 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
|
||||
138
src/test/java/auctiora/ClosingTimeCalculationTest.java
Normal file
138
src/test/java/auctiora/ClosingTimeCalculationTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
390
src/test/java/auctiora/DatabaseServiceTest.java
Normal file
390
src/test/java/auctiora/DatabaseServiceTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
186
src/test/java/auctiora/ImageProcessingServiceTest.java
Normal file
186
src/test/java/auctiora/ImageProcessingServiceTest.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
461
src/test/java/auctiora/IntegrationTest.java
Normal file
461
src/test/java/auctiora/IntegrationTest.java
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
243
src/test/java/auctiora/NotificationServiceTest.java
Normal file
243
src/test/java/auctiora/NotificationServiceTest.java
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
185
src/test/java/auctiora/ObjectDetectionServiceTest.java
Normal file
185
src/test/java/auctiora/ObjectDetectionServiceTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
62
src/test/java/auctiora/ParserTest.java
Normal file
62
src/test/java/auctiora/ParserTest.java
Normal file
File diff suppressed because one or more lines are too long
255
src/test/java/auctiora/ScraperDataAdapterTest.java
Normal file
255
src/test/java/auctiora/ScraperDataAdapterTest.java
Normal 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");
|
||||
}
|
||||
}
|
||||
380
src/test/java/auctiora/TroostwijkMonitorTest.java
Normal file
380
src/test/java/auctiora/TroostwijkMonitorTest.java
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user