fix-tests-cleanup

Former-commit-id: be65f4a5e6
This commit is contained in:
Tour
2025-12-08 07:19:50 +01:00
parent aecf32eb19
commit df919abad5
24 changed files with 4524 additions and 2808 deletions

View File

@@ -99,8 +99,8 @@ brew install opencv
Download YOLO model files for object detection: Download YOLO model files for object detection:
```bash ```bash
mkdir models mkdir /mnt/okcomputer/output/models
cd models cd /mnt/okcomputer/output/models
# Download YOLOv4 config # Download YOLOv4 config
wget https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg wget https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg

View File

@@ -1,189 +0,0 @@
# Kubernetes Deployment for Auction Monitor
## Quick Start
### 1. Build and Push Docker Image
```bash
# Build image
docker build -t your-registry/auction-monitor:latest .
# Push to registry
docker push your-registry/auction-monitor:latest
```
### 2. Update deployment.yaml
Edit `deployment.yaml` and replace:
- `image: auction-monitor:latest` with your image
- `auction-monitor.yourdomain.com` with your domain
### 3. Deploy to Kubernetes
```bash
# Apply all resources
kubectl apply -f k8s/deployment.yaml
# Or apply individually
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
```
### 4. Verify Deployment
```bash
# Check pods
kubectl get pods -n auction-monitor
# Check services
kubectl get svc -n auction-monitor
# Check ingress
kubectl get ingress -n auction-monitor
# View logs
kubectl logs -f deployment/auction-monitor -n auction-monitor
```
### 5. Access Application
```bash
# Port forward for local access
kubectl port-forward svc/auction-monitor 8081:8081 -n auction-monitor
# Access API
curl http://localhost:8081/api/monitor/status
# Access health check
curl http://localhost:8081/health/live
```
## Configuration
### ConfigMap
Edit workflow schedules in `configMap`:
```yaml
data:
AUCTION_WORKFLOW_SCRAPER_IMPORT_CRON: "0 */30 * * * ?" # Every 30 min
AUCTION_WORKFLOW_IMAGE_PROCESSING_CRON: "0 0 * * * ?" # Every 1 hour
AUCTION_WORKFLOW_BID_MONITORING_CRON: "0 */15 * * * ?" # Every 15 min
AUCTION_WORKFLOW_CLOSING_ALERTS_CRON: "0 */5 * * * ?" # Every 5 min
```
### Secrets
Update notification configuration:
```bash
# Create secret
kubectl create secret generic auction-secrets \
--from-literal=notification-config='smtp:user@gmail.com:password:recipient@example.com' \
-n auction-monitor
# Or edit existing
kubectl edit secret auction-secrets -n auction-monitor
```
## Scaling
### Manual Scaling
```bash
# Scale to 3 replicas
kubectl scale deployment auction-monitor --replicas=3 -n auction-monitor
```
### Auto Scaling
HPA is configured in `deployment.yaml`:
```yaml
spec:
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
averageUtilization: 80
```
View HPA status:
```bash
kubectl get hpa -n auction-monitor
```
## Monitoring
### Health Checks
```bash
# Liveness
kubectl exec -it deployment/auction-monitor -n auction-monitor -- \
wget -qO- http://localhost:8081/health/live
# Readiness
kubectl exec -it deployment/auction-monitor -n auction-monitor -- \
wget -qO- http://localhost:8081/health/ready
```
### Logs
```bash
# Follow logs
kubectl logs -f deployment/auction-monitor -n auction-monitor
# Logs from all pods
kubectl logs -f -l app=auction-monitor -n auction-monitor
# Previous pod logs
kubectl logs deployment/auction-monitor --previous -n auction-monitor
```
## Troubleshooting
### Pod not starting
```bash
# Describe pod
kubectl describe pod -l app=auction-monitor -n auction-monitor
# Check events
kubectl get events -n auction-monitor --sort-by='.lastTimestamp'
```
### Database issues
```bash
# Check PVC
kubectl get pvc -n auction-monitor
# Check volume mount
kubectl exec -it deployment/auction-monitor -n auction-monitor -- ls -la /data
```
### Network issues
```bash
# Test service
kubectl run -it --rm debug --image=busybox --restart=Never -n auction-monitor -- \
wget -qO- http://auction-monitor:8081/health/live
```
## Cleanup
```bash
# Delete all resources
kubectl delete -f k8s/deployment.yaml
# Or delete namespace (removes everything)
kubectl delete namespace auction-monitor
```

View File

@@ -1,197 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: auction-monitor
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: auction-data-pvc
namespace: auction-monitor
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: auction-config
namespace: auction-monitor
data:
AUCTION_DATABASE_PATH: "/data/cache.db"
AUCTION_IMAGES_PATH: "/data/images"
AUCTION_NOTIFICATION_CONFIG: "desktop"
QUARKUS_HTTP_PORT: "8081"
QUARKUS_HTTP_HOST: "0.0.0.0"
# Workflow schedules (cron expressions)
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 * * * ?"
---
apiVersion: v1
kind: Secret
metadata:
name: auction-secrets
namespace: auction-monitor
type: Opaque
stringData:
# Replace with your actual SMTP configuration
notification-config: "desktop"
# For email: smtp:your@gmail.com:app_password:recipient@example.com
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: auction-monitor
namespace: auction-monitor
labels:
app: auction-monitor
version: v1
spec:
replicas: 1
selector:
matchLabels:
app: auction-monitor
template:
metadata:
labels:
app: auction-monitor
version: v1
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8081"
prometheus.io/path: "/q/metrics"
spec:
containers:
- name: auction-monitor
image: auction-monitor:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8081
protocol: TCP
env:
- name: JAVA_OPTS
value: "-Xmx256m -XX:+UseParallelGC"
envFrom:
- configMapRef:
name: auction-config
- secretRef:
name: auction-secrets
volumeMounts:
- name: data
mountPath: /data
- name: models
mountPath: /app/models
readOnly: true
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8081
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8081
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/started
port: 8081
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: auction-data-pvc
- name: models
emptyDir: {} # Or mount from ConfigMap/PVC if you have YOLO models
restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
name: auction-monitor
namespace: auction-monitor
labels:
app: auction-monitor
spec:
type: ClusterIP
ports:
- port: 8081
targetPort: 8081
protocol: TCP
name: http
selector:
app: auction-monitor
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auction-monitor-ingress
namespace: auction-monitor
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- auction-monitor.yourdomain.com
secretName: auction-monitor-tls
rules:
- host: auction-monitor.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: auction-monitor
port:
number: 8081
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: auction-monitor-hpa
namespace: auction-monitor
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: auction-monitor
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 80
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

View File

@@ -1,80 +0,0 @@
person
bicycle
car
motorbike
aeroplane
bus
train
truck
boat
traffic light
fire hydrant
stop sign
parking meter
bench
bird
cat
dog
horse
sheep
cow
elephant
bear
zebra
giraffe
backpack
umbrella
handbag
tie
suitcase
frisbee
skis
snowboard
sports ball
kite
baseball bat
baseball glove
skateboard
surfboard
tennis racket
bottle
wine glass
cup
fork
knife
spoon
bowl
banana
apple
sandwich
orange
broccoli
carrot
hot dog
pizza
donut
cake
chair
sofa
pottedplant
bed
diningtable
toilet
tvmonitor
laptop
mouse
remote
keyboard
cell phone
microwave
oven
toaster
sink
refrigerator
book
clock
vase
scissors
teddy bear
hair drier
toothbrush

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
921f4406ecaa54a53031dd695e7bcd96f5dd9be2

17
pom.xml
View File

@@ -34,6 +34,7 @@
<maven-compiler-plugin-version>3.14.0</maven-compiler-plugin-version> <maven-compiler-plugin-version>3.14.0</maven-compiler-plugin-version>
<versions-maven-plugin.version>2.19.0</versions-maven-plugin.version> <versions-maven-plugin.version>2.19.0</versions-maven-plugin.version>
<jandex-maven-plugin-version>3.5.0</jandex-maven-plugin-version> <jandex-maven-plugin-version>3.5.0</jandex-maven-plugin-version>
<jdbi.version>3.47.0</jdbi.version>
<maven.compiler.args> <maven.compiler.args>
--enable-native-access=ALL-UNNAMED --enable-native-access=ALL-UNNAMED
--add-opens java.base/sun.misc=ALL-UNNAMED --add-opens java.base/sun.misc=ALL-UNNAMED
@@ -161,11 +162,11 @@
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.9</version> <version>2.0.9</version>
</dependency> </dependency>
<dependency> <!-- <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId> <artifactId>slf4j-simple</artifactId>
<version>2.0.9</version> <version>2.0.9</version>
</dependency> </dependency>-->
<!-- JUnit 5 for testing --> <!-- JUnit 5 for testing -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
@@ -196,6 +197,18 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- JDBI3 - Lightweight ORM for SQL -->
<dependency>
<groupId>org.jdbi</groupId>
<artifactId>jdbi3-core</artifactId>
<version>${jdbi.version}</version>
</dependency>
<dependency>
<groupId>org.jdbi</groupId>
<artifactId>jdbi3-sqlobject</artifactId>
<version>${jdbi.version}</version>
</dependency>
<!-- AssertJ for fluent assertions (optional but recommended) --> <!-- AssertJ for fluent assertions (optional but recommended) -->
<dependency> <dependency>
<groupId>org.assertj</groupId> <groupId>org.assertj</groupId>

3034
scripts/smb-copy.log Normal file

File diff suppressed because it is too large Load Diff

15
scripts/smb.ps1 Normal file
View File

@@ -0,0 +1,15 @@
# PowerShell: map the remote share, copy the folder, then clean up
$remote = '\\192.168.1.159\shared-auction-data'
$local = 'C:\mnt\okcomputer\output\models'
# (1) create/verify the PSDrive (prompts for password if needed)
if (-not (Get-PSDrive -Name Z -ErrorAction SilentlyContinue)) {
$cred = Get-Credential -UserName 'tour' -Message 'SMB password for tour@192.168.1.159'
New-PSDrive -Name Z -PSProvider FileSystem -Root $remote -Credential $cred -Persist | Out-Null
}
# (2) copy the local folder into the share
Copy-Item -Path $local -Destination 'Z:\' -Recurse -Force
# (3) optional cleanup
Remove-PSDrive -Name Z -Force

View File

@@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import nu.pattern.OpenCV;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.opencv.core.Core; import org.opencv.core.Core;
@@ -24,7 +25,7 @@ public class AuctionMonitorProducer {
@PostConstruct void init() { @PostConstruct void init() {
try { try {
nu.pattern.OpenCV.loadLocally(); OpenCV.loadLocally();
LOG.info("✓ OpenCV loaded successfully"); LOG.info("✓ OpenCV loaded successfully");
} catch (Exception e) { } catch (Exception e) {
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage()); LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());

View File

@@ -1,686 +1,137 @@
package auctiora; package auctiora;
import jakarta.enterprise.context.ApplicationScoped; import auctiora.db.*;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jdbi.v3.core.Jdbi;
import java.io.Console;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Service for persisting auctions, lots, and images into a SQLite database. * Refactored database service using repository pattern and JDBI3.
* Data is typically populated by an external scraper process; * Delegates operations to specialized repositories for better separation of concerns.
* this service enriches it with image processing and monitoring. *
* @deprecated Legacy methods maintained for backward compatibility.
* New code should use repositories directly via dependency injection.
*/ */
@Slf4j @Slf4j
public class DatabaseService { public class DatabaseService {
private final String url; private final Jdbi jdbi;
private final LotRepository lotRepository;
private final AuctionRepository auctionRepository;
private final ImageRepository imageRepository;
DatabaseService(String dbPath) { /**
// Enable WAL mode and busy timeout for concurrent access * Constructor for programmatic instantiation (tests, CLI tools).
this.url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000"; */
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);
} }
/** /**
* Creates tables if they do not already exist. * Constructor with JDBI instance (for dependency injection).
* Schema supports data from external scraper and adds image processing results.
*/ */
void ensureSchema() throws SQLException { public DatabaseService(Jdbi jdbi) {
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) { this.jdbi = jdbi;
// Enable WAL mode for better concurrent access DatabaseSchema.ensureSchema(jdbi);
stmt.execute("PRAGMA journal_mode=WAL");
stmt.execute("PRAGMA busy_timeout=10000");
stmt.execute("PRAGMA synchronous=NORMAL");
// Cache table (for HTTP caching) this.lotRepository = new LotRepository(jdbi);
stmt.execute(""" this.auctionRepository = new AuctionRepository(jdbi);
CREATE TABLE IF NOT EXISTS cache ( this.imageRepository = new ImageRepository(jdbi);
url TEXT PRIMARY KEY,
content BLOB,
timestamp REAL,
status_code INTEGER
)""");
// Auctions table (populated by external scraper)
stmt.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)
stmt.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)
stmt.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
stmt.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)
)""");
// Indexes for performance
stmt.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id)");
stmt.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_lot_url ON images(lot_id, url)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_lot_time ON bid_history(lot_id, bid_time)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
}
} }
/** // ==================== LEGACY COMPATIBILITY METHODS ====================
* Inserts or updates an auction record (typically called by external scraper) // These methods delegate to repositories for backward compatibility
* Handles both auction_id conflicts and url uniqueness constraints
*/
synchronized void upsertAuction(AuctionInfo auction) throws SQLException {
// First try to INSERT with ON CONFLICT on auction_id
var insertSql = """
INSERT INTO auctions (auction_id, title, location, city, country, url, type, lot_count, closing_time, discovered_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
""";
try (var conn = DriverManager.getConnection(url)) { void ensureSchema() {
try (var ps = conn.prepareStatement(insertSql)) { DatabaseSchema.ensureSchema(jdbi);
ps.setLong(1, auction.auctionId());
ps.setString(2, auction.title());
ps.setString(3, auction.location());
ps.setString(4, auction.city());
ps.setString(5, auction.country());
ps.setString(6, auction.url());
ps.setString(7, auction.typePrefix());
ps.setInt(8, auction.lotCount());
ps.setString(9, auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null);
ps.setLong(10, Instant.now().getEpochSecond());
ps.executeUpdate();
} catch (SQLException e) {
// Handle both PRIMARY KEY and URL constraint failures
String errMsg = e.getMessage();
if (errMsg.contains("UNIQUE constraint failed: auctions.auction_id") ||
errMsg.contains("UNIQUE constraint failed: auctions.url") ||
errMsg.contains("PRIMARY KEY constraint failed")) {
// Try updating by URL as fallback (most reliable unique identifier)
var updateByUrlSql = """
UPDATE auctions SET
auction_id = ?,
title = ?,
location = ?,
city = ?,
country = ?,
type = ?,
lot_count = ?,
closing_time = ?
WHERE url = ?
""";
try (var ps = conn.prepareStatement(updateByUrlSql)) {
ps.setLong(1, auction.auctionId());
ps.setString(2, auction.title());
ps.setString(3, auction.location());
ps.setString(4, auction.city());
ps.setString(5, auction.country());
ps.setString(6, auction.typePrefix());
ps.setInt(7, auction.lotCount());
ps.setString(8, auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null);
ps.setString(9, auction.url());
int updated = ps.executeUpdate();
if (updated == 0) {
// Auction doesn't exist by URL either - this is unexpected
log.warn("Could not insert or update auction with url={}, auction_id={} - constraint violation but no existing record found",
auction.url(), auction.auctionId());
} else {
log.debug("Updated existing auction by URL: {}", auction.url());
}
} catch (SQLException updateEx) {
// UPDATE also failed - log and swallow the exception
log.warn("Failed to update auction by URL ({}): {}", auction.url(), updateEx.getMessage());
}
} else {
throw e;
}
}
}
} }
/** synchronized void upsertAuction(AuctionInfo auction) {
* Retrieves all auctions from the database auctionRepository.upsert(auction);
*/
synchronized List<AuctionInfo> getAllAuctions() throws SQLException {
List<AuctionInfo> auctions = new ArrayList<>();
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time FROM auctions";
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
while (rs.next()) {
var closingStr = rs.getString("closing_time");
LocalDateTime closing = null;
if (closingStr != null && !closingStr.isBlank()) {
try {
closing = LocalDateTime.parse(closingStr);
} catch (Exception e) {
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
}
} }
auctions.add(new AuctionInfo( synchronized List<AuctionInfo> getAllAuctions() {
rs.getLong("auction_id"), return auctionRepository.getAll();
rs.getString("title"),
rs.getString("location"),
rs.getString("city"),
rs.getString("country"),
rs.getString("url"),
rs.getString("type"),
rs.getInt("lot_count"),
closing
));
}
}
return auctions;
} }
/** synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) {
* Retrieves auctions by country code return auctionRepository.getByCountry(countryCode);
*/
synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) throws SQLException {
List<AuctionInfo> auctions = new ArrayList<>();
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time "
+ "FROM auctions WHERE country = ?";
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
ps.setString(1, countryCode);
var rs = ps.executeQuery();
while (rs.next()) {
var closingStr = rs.getString("closing_time");
LocalDateTime closing = null;
if (closingStr != null && !closingStr.isBlank()) {
try {
closing = LocalDateTime.parse(closingStr);
} catch (Exception e) {
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
}
} }
auctions.add(new AuctionInfo( synchronized void upsertLot(Lot lot) {
rs.getLong("auction_id"), lotRepository.upsert(lot);
rs.getString("title"),
rs.getString("location"),
rs.getString("city"),
rs.getString("country"),
rs.getString("url"),
rs.getString("type"),
rs.getInt("lot_count"),
closing
));
}
}
return auctions;
} }
/** synchronized void upsertLotWithIntelligence(Lot lot) {
* Inserts or updates a lot record (typically called by external scraper) lotRepository.upsertWithIntelligence(lot);
*/
synchronized void upsertLot(Lot lot) throws SQLException {
// First try to update existing lot by lot_id
var updateSql = """
UPDATE lots SET
sale_id = ?,
title = ?,
description = ?,
manufacturer = ?,
type = ?,
year = ?,
category = ?,
current_bid = ?,
currency = ?,
url = ?,
closing_time = ?
WHERE lot_id = ?
""";
var insertSql = """
INSERT OR IGNORE INTO lots (lot_id, sale_id, title, description, manufacturer, type, year, category, current_bid, currency, url, closing_time, closing_notified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (var conn = DriverManager.getConnection(url)) {
// Try UPDATE first
try (var ps = conn.prepareStatement(updateSql)) {
ps.setString(1, String.valueOf(lot.saleId()));
ps.setString(2, lot.title());
ps.setString(3, lot.description());
ps.setString(4, lot.manufacturer());
ps.setString(5, lot.type());
ps.setInt(6, lot.year());
ps.setString(7, lot.category());
ps.setDouble(8, lot.currentBid());
ps.setString(9, lot.currency());
ps.setString(10, lot.url());
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
ps.setString(12, String.valueOf(lot.lotId()));
int updated = ps.executeUpdate();
if (updated > 0) {
return; // Successfully updated existing record
}
} }
// If no rows updated, try INSERT (ignore if conflicts with UNIQUE constraints) synchronized void updateLotCurrentBid(Lot lot) {
try (var ps = conn.prepareStatement(insertSql)) { lotRepository.updateCurrentBid(lot);
ps.setString(1, String.valueOf(lot.lotId()));
ps.setString(2, String.valueOf(lot.saleId()));
ps.setString(3, lot.title());
ps.setString(4, lot.description());
ps.setString(5, lot.manufacturer());
ps.setString(6, lot.type());
ps.setInt(7, lot.year());
ps.setString(8, lot.category());
ps.setDouble(9, lot.currentBid());
ps.setString(10, lot.currency());
ps.setString(11, lot.url());
ps.setString(12, lot.closingTime() != null ? lot.closingTime().toString() : null);
ps.setInt(13, lot.closingNotified() ? 1 : 0);
ps.executeUpdate();
}
}
} }
/** synchronized void updateLotNotificationFlags(Lot lot) {
* Updates a lot with full intelligence data from GraphQL enrichment. lotRepository.updateNotificationFlags(lot);
* This is a comprehensive update that includes all 24 intelligence fields.
*/
synchronized void upsertLotWithIntelligence(Lot lot) throws SQLException {
var sql = """
UPDATE lots SET
sale_id = ?,
title = ?,
description = ?,
manufacturer = ?,
type = ?,
year = ?,
category = ?,
current_bid = ?,
currency = ?,
url = ?,
closing_time = ?,
followers_count = ?,
estimated_min = ?,
estimated_max = ?,
next_bid_step_in_cents = ?,
condition = ?,
category_path = ?,
city_location = ?,
country_code = ?,
bidding_status = ?,
appearance = ?,
packaging = ?,
quantity = ?,
vat = ?,
buyer_premium_percentage = ?,
remarks = ?,
starting_bid = ?,
reserve_price = ?,
reserve_met = ?,
bid_increment = ?,
view_count = ?,
first_bid_time = ?,
last_bid_time = ?,
bid_velocity = ?
WHERE lot_id = ?
""";
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
ps.setLong(1, lot.saleId());
ps.setString(2, lot.title());
ps.setString(3, lot.description());
ps.setString(4, lot.manufacturer());
ps.setString(5, lot.type());
ps.setInt(6, lot.year());
ps.setString(7, lot.category());
ps.setDouble(8, lot.currentBid());
ps.setString(9, lot.currency());
ps.setString(10, lot.url());
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
// Intelligence fields
if (lot.followersCount() != null) ps.setInt(12, lot.followersCount());
else ps.setNull(12, java.sql.Types.INTEGER);
if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin());
else ps.setNull(13, java.sql.Types.REAL);
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax());
else ps.setNull(14, java.sql.Types.REAL);
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents());
else ps.setNull(15, java.sql.Types.BIGINT);
ps.setString(16, lot.condition());
ps.setString(17, lot.categoryPath());
ps.setString(18, lot.cityLocation());
ps.setString(19, lot.countryCode());
ps.setString(20, lot.biddingStatus());
ps.setString(21, lot.appearance());
ps.setString(22, lot.packaging());
if (lot.quantity() != null) ps.setLong(23, lot.quantity());
else ps.setNull(23, java.sql.Types.BIGINT);
if (lot.vat() != null) ps.setDouble(24, lot.vat());
else ps.setNull(24, java.sql.Types.REAL);
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage());
else ps.setNull(25, java.sql.Types.REAL);
ps.setString(26, lot.remarks());
if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid());
else ps.setNull(27, java.sql.Types.REAL);
if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice());
else ps.setNull(28, java.sql.Types.REAL);
if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0);
else ps.setNull(29, java.sql.Types.INTEGER);
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement());
else ps.setNull(30, java.sql.Types.REAL);
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount());
else ps.setNull(31, java.sql.Types.INTEGER);
ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null);
ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null);
if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity());
else ps.setNull(34, java.sql.Types.REAL);
ps.setLong(35, lot.lotId());
int updated = ps.executeUpdate();
if (updated == 0) {
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
}
}
} }
/** synchronized List<Lot> getActiveLots() {
* Inserts a complete image record (for testing/legacy compatibility). return lotRepository.getActiveLots();
* In production, scraper inserts with local_path, monitor updates labels via updateImageLabels.
*/
synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) throws SQLException {
var sql = "INSERT INTO images (lot_id, url, local_path, labels, processed_at, downloaded) VALUES (?, ?, ?, ?, ?, 1)";
try (var conn = DriverManager.getConnection(this.url); var ps = conn.prepareStatement(sql)) {
ps.setLong(1, lotId);
ps.setString(2, url);
ps.setString(3, filePath);
ps.setString(4, String.join(",", labels));
ps.setLong(5, Instant.now().getEpochSecond());
ps.executeUpdate();
}
} }
/** synchronized List<Lot> getAllLots() {
* Updates the labels field for an image after object detection return lotRepository.getAllLots();
*/
synchronized void updateImageLabels(int imageId, List<String> labels) throws SQLException {
var sql = "UPDATE images SET labels = ?, processed_at = ? WHERE id = ?";
try (var conn = DriverManager.getConnection(this.url); var ps = conn.prepareStatement(sql)) {
ps.setString(1, String.join(",", labels));
ps.setLong(2, Instant.now().getEpochSecond());
ps.setInt(3, imageId);
ps.executeUpdate();
}
} }
/** synchronized List<BidHistory> getBidHistory(String lotId) {
* Gets the labels for a specific image return lotRepository.getBidHistory(lotId);
*/
synchronized List<String> getImageLabels(int imageId) throws SQLException {
var sql = "SELECT labels FROM images WHERE id = ?";
try (var conn = DriverManager.getConnection(this.url); var ps = conn.prepareStatement(sql)) {
ps.setInt(1, imageId);
var rs = ps.executeQuery();
if (rs.next()) {
var labelsStr = rs.getString("labels");
if (labelsStr != null && !labelsStr.isEmpty()) {
return List.of(labelsStr.split(","));
}
}
}
return List.of();
} }
/** synchronized void insertBidHistory(List<BidHistory> bidHistory) {
* Retrieves images for a specific lot lotRepository.insertBidHistory(bidHistory);
*/
synchronized List<ImageRecord> getImagesForLot(long lotId) throws SQLException {
List<ImageRecord> images = new ArrayList<>();
var sql = "SELECT id, lot_id, url, local_path, labels FROM images WHERE lot_id = ?";
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
ps.setLong(1, lotId);
var rs = ps.executeQuery();
while (rs.next()) {
images.add(new ImageRecord(
rs.getInt("id"),
rs.getLong("lot_id"),
rs.getString("url"),
rs.getString("local_path"),
rs.getString("labels")
));
}
}
return images;
} }
/** synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) {
* Retrieves all lots that are active and need monitoring imageRepository.insert(lotId, url, filePath, labels);
*/
synchronized List<Lot> getActiveLots() throws SQLException {
List<Lot> list = new ArrayList<>();
var sql = "SELECT lot_id, sale_id as auction_id, title, description, manufacturer, type, year, category, " +
"current_bid, currency, url, closing_time, closing_notified FROM lots";
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
while (rs.next()) {
try {
// Use ScraperDataAdapter to handle TEXT parsing from legacy database
var lot = ScraperDataAdapter.fromScraperLot(rs);
list.add(lot);
} catch (Exception e) {
log.warn("Failed to parse lot {}: {}", rs.getString("lot_id"), e.getMessage());
}
}
}
return list;
} }
/** synchronized void updateImageLabels(int imageId, List<String> labels) {
* Retrieves all lots from the database imageRepository.updateLabels(imageId, labels);
*/
synchronized List<Lot> getAllLots() throws SQLException {
return getActiveLots();
} }
/** synchronized List<String> getImageLabels(int imageId) {
* Gets the total number of images in the database return imageRepository.getLabels(imageId);
*/
synchronized int getImageCount() throws SQLException {
var sql = "SELECT COUNT(*) as count FROM images";
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
if (rs.next()) {
return rs.getInt("count");
}
}
return 0;
} }
/** synchronized List<ImageRecord> getImagesForLot(long lotId) {
* Updates the current bid of a lot (used by monitoring service) return imageRepository.getImagesForLot(lotId)
*/ .stream()
synchronized void updateLotCurrentBid(Lot lot) throws SQLException { .map(img -> new ImageRecord(img.id(), img.lotId(), img.url(), img.filePath(), img.labels()))
try (var conn = DriverManager.getConnection(url); .toList();
var ps = conn.prepareStatement("UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
ps.setDouble(1, lot.currentBid());
ps.setString(2, String.valueOf(lot.lotId()));
ps.executeUpdate();
}
} }
/** synchronized List<ImageDetectionRecord> getImagesNeedingDetection() {
* Updates the closingNotified flag of a lot return imageRepository.getImagesNeedingDetection()
*/ .stream()
synchronized void updateLotNotificationFlags(Lot lot) throws SQLException { .map(img -> new ImageDetectionRecord(img.id(), img.lotId(), img.filePath()))
try (var conn = DriverManager.getConnection(url); .toList();
var ps = conn.prepareStatement("UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
ps.setInt(1, lot.closingNotified() ? 1 : 0);
ps.setString(2, String.valueOf(lot.lotId()));
ps.executeUpdate();
}
} }
/** synchronized int getImageCount() {
* Retrieves bid history for a specific lot return imageRepository.getImageCount();
*/
synchronized List<BidHistory> getBidHistory(String lotId) throws SQLException {
List<BidHistory> history = new ArrayList<>();
var sql = "SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number " +
"FROM bid_history WHERE lot_id = ? ORDER BY bid_time DESC LIMIT 100";
try (var conn = DriverManager.getConnection(url);
var ps = conn.prepareStatement(sql)) {
ps.setString(1, lotId);
var rs = ps.executeQuery();
while (rs.next()) {
LocalDateTime bidTime = null;
var bidTimeStr = rs.getString("bid_time");
if (bidTimeStr != null && !bidTimeStr.isBlank()) {
try {
bidTime = LocalDateTime.parse(bidTimeStr);
} catch (Exception e) {
log.debug("Invalid bid_time format: {}", bidTimeStr);
}
} }
history.add(new BidHistory( synchronized List<AuctionInfo> importAuctionsFromScraper() {
rs.getInt("id"), return jdbi.withHandle(handle -> {
rs.getString("lot_id"),
rs.getDouble("bid_amount"),
bidTime,
rs.getInt("is_autobid") != 0,
rs.getString("bidder_id"),
rs.getInt("bidder_number")
));
}
}
return history;
}
/**
* Imports auctions from scraper's schema format.
* Since the scraper doesn't populate a separate auctions table,
* we derive auction metadata by aggregating lots data.
*
* @return List of imported auctions
*/
synchronized List<AuctionInfo> importAuctionsFromScraper() throws SQLException {
List<AuctionInfo> imported = new ArrayList<>();
// Derive auctions from lots table (scraper doesn't populate auctions table)
var sql = """ var sql = """
SELECT SELECT
l.auction_id, l.auction_id,
@@ -695,107 +146,73 @@ public class DatabaseService {
GROUP BY l.auction_id GROUP BY l.auction_id
"""; """;
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) { return handle.createQuery(sql)
var rs = stmt.executeQuery(sql); .map((rs, ctx) -> {
while (rs.next()) {
try { try {
var auction = ScraperDataAdapter.fromScraperAuction(rs); var auction = ScraperDataAdapter.fromScraperAuction(rs);
// Skip auctions with invalid IDs (0 indicates parsing failed) if (auction.auctionId() != 0L) {
if (auction.auctionId() == 0L) { auctionRepository.upsert(auction);
log.debug("Skipping auction with invalid ID: auction_id={}", auction.auctionId()); return auction;
continue;
} }
upsertAuction(auction);
imported.add(auction);
} catch (SQLException e) {
// SQLException should be handled by upsertAuction, but if it propagates here, log it
log.warn("Failed to import auction (SQL error): {}", e.getMessage());
} catch (Exception e) { } catch (Exception e) {
// Other exceptions (parsing errors, etc) log.warn("Failed to import auction: {}", e.getMessage());
log.warn("Failed to import auction (parsing error): {}", e.getMessage());
} }
} return null;
} catch (SQLException e) { })
// Table might not exist in scraper format - that's ok .list()
log.info(" Scraper lots table not found or incompatible schema: {}", e.getMessage()); .stream()
.filter(a -> a != null)
.toList();
});
} }
return imported; synchronized List<Lot> importLotsFromScraper() {
} return jdbi.withHandle(handle -> {
var sql = "SELECT * FROM lots";
/** return handle.createQuery(sql)
* Imports lots from scraper's schema format. .map((rs, ctx) -> {
* Reads from scraper's tables and converts to monitor format using adapter.
*
* @return List of imported lots
*/
synchronized List<Lot> importLotsFromScraper() throws SQLException {
List<Lot> imported = new ArrayList<>();
var sql = "SELECT lot_id, auction_id, title, description, category, " +
"current_bid, closing_time, url " +
"FROM lots";
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
while (rs.next()) {
try { try {
var lot = ScraperDataAdapter.fromScraperLot(rs); var lot = ScraperDataAdapter.fromScraperLot(rs);
// Skip lots with invalid IDs (0 indicates parsing failed) if (lot.lotId() != 0L && lot.saleId() != 0L) {
if (lot.lotId() == 0L || lot.saleId() == 0L) { lotRepository.upsert(lot);
log.debug("Skipping lot with invalid ID: lot_id={}, sale_id={}", lot.lotId(), lot.saleId()); return lot;
continue;
} }
upsertLot(lot);
imported.add(lot);
} catch (Exception e) { } catch (Exception e) {
System.err.println("Failed to import lot: " + e.getMessage()); log.warn("Failed to import lot: {}", e.getMessage());
} }
} return null;
} catch (SQLException e) { })
// Table might not exist in scraper format - that's ok .list()
log.info(" Scraper lots table not found or incompatible schema"); .stream()
.filter(l -> l != null)
.toList();
});
} }
return imported; // ==================== DIRECT REPOSITORY ACCESS ====================
// Expose repositories for modern usage patterns
public LotRepository lots() {
return lotRepository;
} }
/** public AuctionRepository auctions() {
* Gets images that have been downloaded by the scraper but need object detection. return auctionRepository;
* Only returns images that have local_path set but no labels yet.
*
* @return List of images needing object detection
*/
synchronized List<ImageDetectionRecord> getImagesNeedingDetection() throws SQLException {
List<ImageDetectionRecord> images = new ArrayList<>();
var sql = """
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 = '')
""";
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
while (rs.next()) {
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
String lotIdStr = rs.getString("lot_id");
long lotId = ScraperDataAdapter.extractNumericId(lotIdStr);
images.add(new ImageDetectionRecord(
rs.getInt("id"),
lotId,
rs.getString("local_path")
));
}
} catch (SQLException e) {
log.info(" No images needing detection found");
} }
return images; public ImageRepository images() {
return imageRepository;
} }
record ImageRecord(int id, long lotId, String url, String filePath, String labels) { } public Jdbi getJdbi() {
return jdbi;
record ImageDetectionRecord(int id, long lotId, String filePath) { } }
// ==================== 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

@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
/// Data typically populated by the external scraper process. /// Data typically populated by the external scraper process.
/// This project enriches the data with image analysis and monitoring. /// This project enriches the data with image analysis and monitoring.
@With @With
record Lot( public record Lot(
long saleId, long saleId,
long lotId, long lotId,
String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries

View File

@@ -60,7 +60,7 @@ public class TroostwijkMonitor {
for (var lot : activeLots) { for (var lot : activeLots) {
checkAndUpdateLot(lot); checkAndUpdateLot(lot);
} }
} catch (SQLException e) { } catch (Exception e) {
log.error("Error during scheduled monitoring", e); log.error("Error during scheduled monitoring", e);
} }
} }
@@ -74,11 +74,7 @@ public class TroostwijkMonitor {
notifier.sendNotification( notifier.sendNotification(
"Kavel " + lot.lotId() + " sluit binnen " + minutesLeft + " min.", "Kavel " + lot.lotId() + " sluit binnen " + minutesLeft + " min.",
"Lot nearing closure", 1); "Lot nearing closure", 1);
try {
db.updateLotNotificationFlags(lot.withClosingNotified(true)); db.updateLotNotificationFlags(lot.withClosingNotified(true));
} catch (SQLException e) {
throw new RuntimeException(e);
}
} }
scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES); scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
} }
@@ -109,7 +105,7 @@ public class TroostwijkMonitor {
notifier.sendNotification(msg, "Kavel bieding update", 0); notifier.sendNotification(msg, "Kavel bieding update", 0);
} }
} }
} catch (IOException | InterruptedException | SQLException e) { } catch (IOException | InterruptedException e) {
log.warn("Failed to refresh bid for lot {}", lot.lotId(), e); log.warn("Failed to refresh bid for lot {}", lot.lotId(), e);
if (e instanceof InterruptedException) Thread.currentThread().interrupt(); if (e instanceof InterruptedException) Thread.currentThread().interrupt();
} }
@@ -125,7 +121,7 @@ public class TroostwijkMonitor {
var sum = allLots.stream().mapToDouble(Lot::currentBid).sum(); var sum = allLots.stream().mapToDouble(Lot::currentBid).sum();
log.info("Total current bids: €{:.2f}", sum); log.info("Total current bids: €{:.2f}", sum);
} }
} catch (SQLException e) { } catch (Exception e) {
log.warn("Could not retrieve database stats", e); log.warn("Could not retrieve database stats", e);
} }
} }

View File

@@ -0,0 +1,164 @@
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;
/**
* 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
String 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());
int 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) -> {
String 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()
);
}
/**
* 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) -> {
String closingStr = rs.getString("closing_time");
LocalDateTime closingTime = null;
if (closingStr != null && !closingStr.isBlank()) {
try {
closingTime = LocalDateTime.parse(closingStr);
} catch (Exception e) {
log.warn("Invalid closing_time format: {}", closingStr);
}
}
return new AuctionInfo(
rs.getLong("auction_id"),
rs.getString("title"),
rs.getString("location"),
rs.getString("city"),
rs.getString("country"),
rs.getString("url"),
rs.getString("type"),
rs.getInt("lot_count"),
closingTime
);
})
.list()
);
}
}

View File

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

View File

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

View File

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

View File

@@ -44,12 +44,12 @@ quarkus.rest.path=/
quarkus.http.root-path=/ quarkus.http.root-path=/
# Auction Monitor Configuration # Auction Monitor Configuration
auction.database.path=C:\\mnt\\okcomputer\\output\\cache.db auction.database.path=/mnt/okcomputer/output/cache.db
auction.images.path=C:\\mnt\\okcomputer\\output\\images auction.images.path=/mnt/okcomputer/output/images
auction.notification.config=desktop auction.notification.config=desktop
auction.yolo.config=models/yolov4.cfg auction.yolo.config=/mnt/okcomputer/output/models/yolov4.cfg
auction.yolo.weights=models/yolov4.weights auction.yolo.weights=/mnt/okcomputer/output/models/yolov4.weights
auction.yolo.classes=models/coco.names auction.yolo.classes=/mnt/okcomputer/output/models/coco.names
# Scheduler Configuration # Scheduler Configuration
quarkus.scheduler.enabled=true quarkus.scheduler.enabled=true

View File

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

View File

@@ -357,7 +357,7 @@ class DatabaseServiceTest {
100.0, "EUR", "https://example.com/" + i, null, false 100.0, "EUR", "https://example.com/" + i, null, false
)); ));
} }
} catch (SQLException e) { } catch (Exception e) {
fail("Thread 1 failed: " + e.getMessage()); fail("Thread 1 failed: " + e.getMessage());
} }
}); });
@@ -370,7 +370,7 @@ class DatabaseServiceTest {
200.0, "EUR", "https://example.com/" + i, null, false 200.0, "EUR", "https://example.com/" + i, null, false
)); ));
} }
} catch (SQLException e) { } catch (Exception e) {
fail("Thread 2 failed: " + e.getMessage()); fail("Thread 2 failed: " + e.getMessage());
} }
}); });

View File

@@ -72,13 +72,13 @@ class ImageProcessingServiceTest {
@Test @Test
@DisplayName("Should handle database error gracefully") @DisplayName("Should handle database error gracefully")
void testProcessImageDatabaseError() throws SQLException { void testProcessImageDatabaseError() {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/'); String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath)) when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("object")); .thenReturn(List.of("object"));
doThrow(new SQLException("Database error")) doThrow(new RuntimeException("Database error"))
.when(mockDb).updateImageLabels(anyInt(), anyList()); .when(mockDb).updateImageLabels(anyInt(), anyList());
// Should return false on error // Should return false on error
@@ -162,9 +162,9 @@ class ImageProcessingServiceTest {
@Test @Test
@DisplayName("Should handle database query error in batch processing") @DisplayName("Should handle database query error in batch processing")
void testProcessPendingImagesDatabaseError() throws SQLException { void testProcessPendingImagesDatabaseError() {
when(mockDb.getImagesNeedingDetection()) when(mockDb.getImagesNeedingDetection())
.thenThrow(new SQLException("Database connection failed")); .thenThrow(new RuntimeException("Database connection failed"));
// Should not throw exception // Should not throw exception
assertDoesNotThrow(() -> service.processPendingImages()); assertDoesNotThrow(() -> service.processPendingImages());

View File

@@ -370,7 +370,7 @@ class IntegrationTest {
"https://example.com/60" + i, "A1", 5, null "https://example.com/60" + i, "A1", 5, null
)); ));
} }
} catch (SQLException e) { } catch (Exception e) {
fail("Auction thread failed: " + e.getMessage()); fail("Auction thread failed: " + e.getMessage());
} }
}); });
@@ -383,7 +383,7 @@ class IntegrationTest {
100.0 * i, "EUR", "https://example.com/70" + i, null, false 100.0 * i, "EUR", "https://example.com/70" + i, null, false
)); ));
} }
} catch (SQLException e) { } catch (Exception e) {
fail("Lot thread failed: " + e.getMessage()); fail("Lot thread failed: " + e.getMessage());
} }
}); });

View File

@@ -21,7 +21,6 @@ class ObjectDetectionServiceTest {
@Test @Test
@DisplayName("Should initialize with missing YOLO models (disabled mode)") @DisplayName("Should initialize with missing YOLO models (disabled mode)")
void testInitializeWithoutModels() throws IOException { void testInitializeWithoutModels() throws IOException {
// When models don't exist, service should initialize in disabled mode
ObjectDetectionService service = new ObjectDetectionService( ObjectDetectionService service = new ObjectDetectionService(
"non_existent.cfg", "non_existent.cfg",
"non_existent.weights", "non_existent.weights",
@@ -84,7 +83,6 @@ class ObjectDetectionServiceTest {
@Test @Test
@DisplayName("Should throw IOException when model files exist but OpenCV fails to load") @DisplayName("Should throw IOException when model files exist but OpenCV fails to load")
void testInitializeWithValidModels() throws IOException { void testInitializeWithValidModels() throws IOException {
// Create dummy model files for testing initialization
var cfgPath = Paths.get(TEST_CFG); var cfgPath = Paths.get(TEST_CFG);
var weightsPath = Paths.get(TEST_WEIGHTS); var weightsPath = Paths.get(TEST_WEIGHTS);
var classesPath = Paths.get(TEST_CLASSES); var classesPath = Paths.get(TEST_CLASSES);

View File

@@ -24,7 +24,6 @@ class TroostwijkMonitorTest {
void setUp() throws SQLException, IOException { void setUp() throws SQLException, IOException {
testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db"; testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db";
// Initialize with non-existent YOLO models (disabled mode)
monitor = new TroostwijkMonitor( monitor = new TroostwijkMonitor(
testDbPath, testDbPath,
"desktop", "desktop",
@@ -292,7 +291,7 @@ class TroostwijkMonitorTest {
100.0, "EUR", "https://example.com/" + i, null, false 100.0, "EUR", "https://example.com/" + i, null, false
)); ));
} }
} catch (SQLException e) { } catch (Exception e) {
fail("Thread 1 failed: " + e.getMessage()); fail("Thread 1 failed: " + e.getMessage());
} }
}); });
@@ -305,7 +304,7 @@ class TroostwijkMonitorTest {
200.0, "EUR", "https://example.com/" + i, null, false 200.0, "EUR", "https://example.com/" + i, null, false
)); ));
} }
} catch (SQLException e) { } catch (Exception e) {
fail("Thread 2 failed: " + e.getMessage()); fail("Thread 2 failed: " + e.getMessage());
} }
}); });