fix-tests-cleanup

This commit is contained in:
Tour
2025-12-08 13:02:21 +01:00
parent 6668ae77e9
commit c46c0fe21e
4 changed files with 351 additions and 234 deletions

View File

@@ -4,6 +4,8 @@ import auctiora.db.*;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.core.Jdbi; import org.jdbi.v3.core.Jdbi;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List; import java.util.List;
/** /**
@@ -15,204 +17,254 @@ import java.util.List;
*/ */
@Slf4j @Slf4j
public class DatabaseService { public class DatabaseService {
private final Jdbi jdbi; private final Jdbi jdbi;
private final LotRepository lotRepository; private final LotRepository lotRepository;
private final AuctionRepository auctionRepository; private final AuctionRepository auctionRepository;
private final ImageRepository imageRepository; private final ImageRepository imageRepository;
/** /**
* Constructor for programmatic instantiation (tests, CLI tools). * Constructor for programmatic instantiation (tests, CLI tools).
*/ */
public DatabaseService(String dbPath) { private final String url;
String url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
this.jdbi = Jdbi.create(url); public DatabaseService(String dbPath) {
this.url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
// Initialize schema this.jdbi = Jdbi.create(url);
DatabaseSchema.ensureSchema(jdbi);
// Initialize schema
// Create repositories DatabaseSchema.ensureSchema(jdbi);
this.lotRepository = new LotRepository(jdbi);
this.auctionRepository = new AuctionRepository(jdbi); // Create repositories
this.imageRepository = new ImageRepository(jdbi); 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) { * Constructor with JDBI instance (for dependency injection).
this.jdbi = jdbi; */
DatabaseSchema.ensureSchema(jdbi); public DatabaseService(Jdbi jdbi) {
this.jdbi = jdbi;
this.lotRepository = new LotRepository(jdbi); this.url = null; // Use null as this constructor doesn't use the URL
this.auctionRepository = new AuctionRepository(jdbi);
this.imageRepository = new ImageRepository(jdbi); DatabaseSchema.ensureSchema(jdbi);
}
this.lotRepository = new LotRepository(jdbi);
// ==================== LEGACY COMPATIBILITY METHODS ==================== this.auctionRepository = new AuctionRepository(jdbi);
// These methods delegate to repositories for backward compatibility this.imageRepository = new ImageRepository(jdbi);
}
void ensureSchema() {
DatabaseSchema.ensureSchema(jdbi); // ==================== LEGACY COMPATIBILITY METHODS ====================
} // These methods delegate to repositories for backward compatibility
synchronized void upsertAuction(AuctionInfo auction) { void ensureSchema() {
auctionRepository.upsert(auction); DatabaseSchema.ensureSchema(jdbi);
} }
synchronized List<AuctionInfo> getAllAuctions() { synchronized void upsertAuction(AuctionInfo auction) {
return auctionRepository.getAll(); auctionRepository.upsert(auction);
} }
synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) { synchronized List<AuctionInfo> getAllAuctions() {
return auctionRepository.getByCountry(countryCode); return auctionRepository.getAll();
} }
synchronized void upsertLot(Lot lot) { synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) {
lotRepository.upsert(lot); return auctionRepository.getByCountry(countryCode);
} }
synchronized void upsertLotWithIntelligence(Lot lot) { synchronized void upsertLot(Lot lot) {
lotRepository.upsertWithIntelligence(lot); retry(() -> {
} try (var connection = DriverManager.getConnection(url)) {
connection.setAutoCommit(false); // Start transaction
synchronized void updateLotCurrentBid(Lot lot) { lotRepository.upsert(lot); // Perform update
lotRepository.updateCurrentBid(lot); connection.commit(); // Commit transaction
} } catch (SQLException e) {
throw new RuntimeException("Failed to upsert lot", e);
synchronized void updateLotNotificationFlags(Lot lot) { }
lotRepository.updateNotificationFlags(lot); });
} }
synchronized List<Lot> getActiveLots() { void upsertLots(List<Lot> lots) { // Batch import with transactions
return lotRepository.getActiveLots(); retry(() -> {
} try (var connection = DriverManager.getConnection(url)) {
connection.setAutoCommit(false); // Start transaction
synchronized List<Lot> getAllLots() { for (Lot lot : lots) {
return lotRepository.getAllLots(); lotRepository.upsert(lot); // Upsert individual lot
} }
connection.commit(); // Commit transaction
synchronized List<BidHistory> getBidHistory(String lotId) { } catch (SQLException e) {
return lotRepository.getBidHistory(lotId); throw new RuntimeException("Failed to upsert lots", e);
} }
});
synchronized void insertBidHistory(List<BidHistory> bidHistory) { }
lotRepository.insertBidHistory(bidHistory);
} // Retry logic for transient database failures
private void retry(Runnable action) {
synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) { final int maxRetries = 3;
imageRepository.insert(lotId, url, filePath, labels); for (int attempt = 1; attempt <= maxRetries; attempt++) {
} try {
action.run(); // Attempt action
synchronized void updateImageLabels(int imageId, List<String> labels) { return; // Exit on success
imageRepository.updateLabels(imageId, labels); } catch (RuntimeException e) {
} boolean isBusy = e.getCause() instanceof SQLException
&& e.getCause().getMessage().contains("[SQLITE_BUSY]");
synchronized List<String> getImageLabels(int imageId) { if (isBusy && attempt < maxRetries) {
return imageRepository.getLabels(imageId); log.warn("Database locked, retrying {} of {}", attempt, maxRetries);
} try {
Thread.sleep(500L * attempt); // Backoff
synchronized List<ImageRecord> getImagesForLot(long lotId) { } catch (InterruptedException interrupted) {
return imageRepository.getImagesForLot(lotId) Thread.currentThread().interrupt();
.stream() }
.map(img -> new ImageRecord(img.id(), img.lotId(), img.url(), img.filePath(), img.labels())) } else {
.toList(); throw e; // Non-retryable error or max retries exceeded
} }
}
synchronized List<ImageDetectionRecord> getImagesNeedingDetection() { }
return imageRepository.getImagesNeedingDetection() }
.stream()
.map(img -> new ImageDetectionRecord(img.id(), img.lotId(), img.filePath())) synchronized void upsertLotWithIntelligence(Lot lot) {
.toList(); lotRepository.upsertWithIntelligence(lot);
} }
synchronized int getImageCount() { synchronized void updateLotCurrentBid(Lot lot) {
return imageRepository.getImageCount(); lotRepository.updateCurrentBid(lot);
} }
synchronized List<AuctionInfo> importAuctionsFromScraper() { synchronized void updateLotNotificationFlags(Lot lot) {
return jdbi.withHandle(handle -> { lotRepository.updateNotificationFlags(lot);
var sql = """ }
SELECT
l.auction_id, synchronized List<Lot> getActiveLots() {
MIN(l.title) as title, return lotRepository.getActiveLots();
MIN(l.location) as location, }
MIN(l.url) as url,
COUNT(*) as lots_count, synchronized List<Lot> getAllLots() {
MIN(l.closing_time) as first_lot_closing_time, return lotRepository.getAllLots();
MIN(l.scraped_at) as scraped_at }
FROM lots l
WHERE l.auction_id IS NOT NULL synchronized List<BidHistory> getBidHistory(String lotId) {
GROUP BY l.auction_id return lotRepository.getBidHistory(lotId);
"""; }
return handle.createQuery(sql) synchronized void insertBidHistory(List<BidHistory> bidHistory) {
.map((rs, ctx) -> { lotRepository.insertBidHistory(bidHistory);
try { }
var auction = ScraperDataAdapter.fromScraperAuction(rs);
if (auction.auctionId() != 0L) { synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) {
auctionRepository.upsert(auction); imageRepository.insert(lotId, url, filePath, labels);
return auction; }
}
} catch (Exception e) { synchronized void updateImageLabels(int imageId, List<String> labels) {
log.warn("Failed to import auction: {}", e.getMessage()); imageRepository.updateLabels(imageId, labels);
} }
return null;
}) synchronized List<String> getImageLabels(int imageId) {
.list() return imageRepository.getLabels(imageId);
.stream() }
.filter(a -> a != null)
.toList(); 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()))
synchronized List<Lot> importLotsFromScraper() { .toList();
return jdbi.withHandle(handle -> { }
var sql = "SELECT * FROM lots";
synchronized List<ImageDetectionRecord> getImagesNeedingDetection() {
return handle.createQuery(sql) return imageRepository.getImagesNeedingDetection()
.map((rs, ctx) -> { .stream()
try { .map(img -> new ImageDetectionRecord(img.id(), img.lotId(), img.filePath()))
var lot = ScraperDataAdapter.fromScraperLot(rs); .toList();
if (lot.lotId() != 0L && lot.saleId() != 0L) { }
lotRepository.upsert(lot);
return lot; synchronized int getImageCount() {
} return imageRepository.getImageCount();
} catch (Exception e) { }
log.warn("Failed to import lot: {}", e.getMessage());
} synchronized List<AuctionInfo> importAuctionsFromScraper() {
return null; return jdbi.withHandle(handle -> {
}) var sql = """
.list() SELECT
.stream() l.auction_id,
.filter(l -> l != null) MIN(l.title) as title,
.toList(); MIN(l.location) as location,
}); MIN(l.url) as url,
} COUNT(*) as lots_count,
MIN(l.closing_time) as first_lot_closing_time,
// ==================== DIRECT REPOSITORY ACCESS ==================== MIN(l.scraped_at) as scraped_at
// Expose repositories for modern usage patterns FROM lots l
WHERE l.auction_id IS NOT NULL
public LotRepository lots() { GROUP BY l.auction_id
return lotRepository; """;
}
return handle.createQuery(sql)
public AuctionRepository auctions() { .map((rs, ctx) -> {
return auctionRepository; try {
} var auction = ScraperDataAdapter.fromScraperAuction(rs);
if (auction.auctionId() != 0L) {
public ImageRepository images() { auctionRepository.upsert(auction);
return imageRepository; return auction;
} }
} catch (Exception e) {
public Jdbi getJdbi() { log.warn("Failed to import auction: {}", e.getMessage());
return jdbi; }
} return null;
})
// ==================== LEGACY RECORDS ==================== .list()
// Keep records for backward compatibility with existing code .stream()
.filter(a -> a != null)
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) {} .toList();
});
public record ImageDetectionRecord(int id, long lotId, String filePath) {} }
synchronized List<Lot> importLotsFromScraper() {
return jdbi.withHandle(handle -> {
var sql = "SELECT * FROM lots";
return handle.createQuery(sql)
.map((rs, ctx) -> {
try {
var lot = ScraperDataAdapter.fromScraperLot(rs);
if (lot.lotId() != 0L && lot.saleId() != 0L) {
lotRepository.upsert(lot);
return lot;
}
} catch (Exception e) {
log.warn("Failed to import lot: {}", e.getMessage());
}
return null;
})
.list()
.stream()
.filter(l -> l != null)
.toList();
});
}
// ==================== DIRECT REPOSITORY ACCESS ====================
// Expose repositories for modern usage patterns
public LotRepository lots() {
return lotRepository;
}
public AuctionRepository auctions() {
return auctionRepository;
}
public ImageRepository images() {
return imageRepository;
}
public Jdbi getJdbi() {
return jdbi;
}
// ==================== LEGACY RECORDS ====================
// Keep records for backward compatibility with existing code
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) { }
public record ImageDetectionRecord(int id, long lotId, String filePath) { }
} }

View File

@@ -45,32 +45,32 @@ public record NotificationService(Config cfg) {
var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO; var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO;
icon.displayMessage(title, msg, type); icon.displayMessage(title, msg, type);
// Remove tray icon asynchronously to avoid blocking the caller // Remove tray icon asynchronously to avoid blocking the caller
int delayMs = Integer.getInteger("auctiora.desktop.delay.ms", 0); int delayMs = Integer.getInteger("auctiora.desktop.delay.ms", 0);
if (delayMs <= 0) { if (delayMs <= 0) {
var t = new Thread(() -> { var t = new Thread(() -> {
try { try {
Thread.sleep(50); Thread.sleep(50);
} catch (InterruptedException ignored) { } catch (InterruptedException ignored) {
} }
try { try {
tray.remove(icon); tray.remove(icon);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
}, "tray-remove"); }, "tray-remove");
t.setDaemon(true); t.setDaemon(true);
t.start(); t.start();
} else { } else {
try { try {
Thread.sleep(delayMs); Thread.sleep(delayMs);
} catch (InterruptedException ignored) { } catch (InterruptedException ignored) {
} finally { } finally {
try { try {
tray.remove(icon); tray.remove(icon);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
} }
} }
log.info("Desktop notification: {}", title); log.info("Desktop notification: {}", title);
} catch (Exception e) { } catch (Exception e) {
@@ -88,34 +88,34 @@ public record NotificationService(Config cfg) {
props.put("mail.smtp.port", "587"); props.put("mail.smtp.port", "587");
props.put("mail.smtp.ssl.trust", "smtp.gmail.com"); props.put("mail.smtp.ssl.trust", "smtp.gmail.com");
props.put("mail.smtp.ssl.protocols", "TLSv1.2"); props.put("mail.smtp.ssl.protocols", "TLSv1.2");
// Connection timeouts (configurable; short during tests, longer otherwise) // Connection timeouts (configurable; short during tests, longer otherwise)
int smtpTimeoutMs = Integer.getInteger("auctiora.smtp.timeout.ms", isUnderTest() ? 200 : 10000); int smtpTimeoutMs = Integer.getInteger("auctiora.smtp.timeout.ms", isUnderTest() ? 200 : 10000);
String t = String.valueOf(smtpTimeoutMs); String t = String.valueOf(smtpTimeoutMs);
props.put("mail.smtp.connectiontimeout", t); props.put("mail.smtp.connectiontimeout", t);
props.put("mail.smtp.timeout", t); props.put("mail.smtp.timeout", t);
props.put("mail.smtp.writetimeout", t); props.put("mail.smtp.writetimeout", t);
var session = Session.getInstance(props, new Authenticator() { var session = Session.getInstance(props, new Authenticator() {
@Override @Override
protected PasswordAuthentication getPasswordAuthentication() { protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(cfg.smtpUsername(), cfg.smtpPassword()); return new PasswordAuthentication(cfg.smtpUsername(), cfg.smtpPassword());
} }
}); });
var m = new MimeMessage(session); var m = new MimeMessage(session);
m.setFrom(new InternetAddress(cfg.smtpUsername())); m.setFrom(new InternetAddress(cfg.smtpUsername()));
m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(cfg.toEmail())); m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(cfg.toEmail()));
m.setSubject("[Troostwijk] " + title); m.setSubject("[Troostwijk] " + title);
m.setText(msg); m.setText(msg);
m.setSentDate(new Date()); m.setSentDate(new Date());
if (prio > 0) { if (prio > 0) {
m.setHeader("X-Priority", "1"); m.setHeader("X-Priority", "1");
m.setHeader("Importance", "High"); m.setHeader("Importance", "High");
} }
Transport.send(m); Transport.send(m);
log.info("Email notification sent: {}", title); log.info("Email notification sent: {}", title);
} catch (javax.mail.AuthenticationFailedException e) { } catch (javax.mail.AuthenticationFailedException e) {
@@ -151,15 +151,15 @@ public record NotificationService(Config cfg) {
throw new IllegalArgumentException("Use 'desktop' or 'smtp:username:password:toEmail'"); throw new IllegalArgumentException("Use 'desktop' or 'smtp:username:password:toEmail'");
} }
} }
private static boolean isUnderTest() { private static boolean isUnderTest() {
try { try {
// Explicit override // Explicit override
if (Boolean.getBoolean("auctiora.test")) return true; if (Boolean.getBoolean("auctiora.test")) return true;
// Maven Surefire commonly sets this property // Maven Surefire commonly sets this property
if (System.getProperty("surefire.test.class.path") != null) return true; if (System.getProperty("surefire.test.class.path") != null) return true;
// Fallback: check classpath hint // Fallback: check classpath hint
String cp = System.getProperty("java.class.path", ""); String cp = System.getProperty("java.class.path", "");
return cp.contains("surefire") || cp.contains("junit"); return cp.contains("surefire") || cp.contains("junit");

View File

@@ -113,7 +113,7 @@ class NotificationServiceTest {
@DisplayName("Should include both desktop and email when SMTP configured") @DisplayName("Should include both desktop and email when SMTP configured")
void testBothNotificationChannels() { void testBothNotificationChannels() {
var service = new NotificationService( var service = new NotificationService(
"smtp:user@gmail.com:password:recipient@example.com" "smtp:michael.bakker1986@gmail.com:agrepolhlnvhipkv:michael.bakker1986@gmail.com"
); );
// Both desktop and email should be attempted // Both desktop and email should be attempted

View File

@@ -0,0 +1,65 @@
# 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
# 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