@@ -99,8 +99,8 @@ brew install opencv
|
||||
Download YOLO model files for object detection:
|
||||
|
||||
```bash
|
||||
mkdir models
|
||||
cd models
|
||||
mkdir /mnt/okcomputer/output/models
|
||||
cd /mnt/okcomputer/output/models
|
||||
|
||||
# Download YOLOv4 config
|
||||
wget https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg
|
||||
|
||||
189
k8s/README.md
189
k8s/README.md
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
1158
models/yolov4.cfg
1158
models/yolov4.cfg
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
921f4406ecaa54a53031dd695e7bcd96f5dd9be2
|
||||
17
pom.xml
17
pom.xml
@@ -34,6 +34,7 @@
|
||||
<maven-compiler-plugin-version>3.14.0</maven-compiler-plugin-version>
|
||||
<versions-maven-plugin.version>2.19.0</versions-maven-plugin.version>
|
||||
<jandex-maven-plugin-version>3.5.0</jandex-maven-plugin-version>
|
||||
<jdbi.version>3.47.0</jdbi.version>
|
||||
<maven.compiler.args>
|
||||
--enable-native-access=ALL-UNNAMED
|
||||
--add-opens java.base/sun.misc=ALL-UNNAMED
|
||||
@@ -161,11 +162,11 @@
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.9</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- <dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>2.0.9</version>
|
||||
</dependency>
|
||||
</dependency>-->
|
||||
<!-- JUnit 5 for testing -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
@@ -196,6 +197,18 @@
|
||||
<scope>test</scope>
|
||||
</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) -->
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
|
||||
3034
scripts/smb-copy.log
Normal file
3034
scripts/smb-copy.log
Normal file
File diff suppressed because it is too large
Load Diff
15
scripts/smb.ps1
Normal file
15
scripts/smb.ps1
Normal 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
|
||||
@@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
import jakarta.inject.Singleton;
|
||||
import nu.pattern.OpenCV;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.opencv.core.Core;
|
||||
@@ -24,7 +25,7 @@ public class AuctionMonitorProducer {
|
||||
|
||||
@PostConstruct void init() {
|
||||
try {
|
||||
nu.pattern.OpenCV.loadLocally();
|
||||
OpenCV.loadLocally();
|
||||
LOG.info("✓ OpenCV loaded successfully");
|
||||
} catch (Exception e) {
|
||||
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
|
||||
/// Data typically populated by the external scraper process.
|
||||
/// This project enriches the data with image analysis and monitoring.
|
||||
@With
|
||||
record Lot(
|
||||
public record Lot(
|
||||
long saleId,
|
||||
long lotId,
|
||||
String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries
|
||||
|
||||
@@ -60,25 +60,21 @@ public class TroostwijkMonitor {
|
||||
for (var lot : activeLots) {
|
||||
checkAndUpdateLot(lot);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
} catch (Exception e) {
|
||||
log.error("Error during scheduled monitoring", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void checkAndUpdateLot(Lot lot) {
|
||||
refreshLotBid(lot);
|
||||
|
||||
|
||||
var minutesLeft = lot.minutesUntilClose();
|
||||
if (minutesLeft < 30) {
|
||||
if (minutesLeft <= 5 && !lot.closingNotified()) {
|
||||
notifier.sendNotification(
|
||||
"Kavel " + lot.lotId() + " sluit binnen " + minutesLeft + " min.",
|
||||
"Lot nearing closure", 1);
|
||||
try {
|
||||
db.updateLotNotificationFlags(lot.withClosingNotified(true));
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
db.updateLotNotificationFlags(lot.withClosingNotified(true));
|
||||
}
|
||||
scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
|
||||
}
|
||||
@@ -109,12 +105,12 @@ public class TroostwijkMonitor {
|
||||
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);
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void printDatabaseStats() {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
@@ -125,7 +121,7 @@ public class TroostwijkMonitor {
|
||||
var sum = allLots.stream().mapToDouble(Lot::currentBid).sum();
|
||||
log.info("Total current bids: €{:.2f}", sum);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not retrieve database stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
164
src/main/java/auctiora/db/AuctionRepository.java
Normal file
164
src/main/java/auctiora/db/AuctionRepository.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
154
src/main/java/auctiora/db/DatabaseSchema.java
Normal file
154
src/main/java/auctiora/db/DatabaseSchema.java
Normal file
@@ -0,0 +1,154 @@
|
||||
package auctiora.db;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import org.jdbi.v3.core.Jdbi;
|
||||
|
||||
/**
|
||||
* Database schema DDL definitions for all tables and indexes.
|
||||
* Uses text blocks (Java 15+) for clean SQL formatting.
|
||||
*/
|
||||
@UtilityClass
|
||||
public class DatabaseSchema {
|
||||
|
||||
/**
|
||||
* Initializes all database tables and indexes if they don't exist.
|
||||
*/
|
||||
public void ensureSchema(Jdbi jdbi) {
|
||||
jdbi.useHandle(handle -> {
|
||||
// Enable WAL mode for better concurrent access
|
||||
handle.execute("PRAGMA journal_mode=WAL");
|
||||
handle.execute("PRAGMA busy_timeout=10000");
|
||||
handle.execute("PRAGMA synchronous=NORMAL");
|
||||
|
||||
createTables(handle);
|
||||
createIndexes(handle);
|
||||
});
|
||||
}
|
||||
|
||||
private void createTables(org.jdbi.v3.core.Handle handle) {
|
||||
// Cache table (for HTTP caching)
|
||||
handle.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
content BLOB,
|
||||
timestamp REAL,
|
||||
status_code INTEGER
|
||||
)""");
|
||||
|
||||
// Auctions table (populated by external scraper)
|
||||
handle.execute("""
|
||||
CREATE TABLE IF NOT EXISTS auctions (
|
||||
auction_id TEXT PRIMARY KEY,
|
||||
url TEXT UNIQUE,
|
||||
title TEXT,
|
||||
location TEXT,
|
||||
lots_count INTEGER,
|
||||
first_lot_closing_time TEXT,
|
||||
scraped_at TEXT,
|
||||
city TEXT,
|
||||
country TEXT,
|
||||
type TEXT,
|
||||
lot_count INTEGER DEFAULT 0,
|
||||
closing_time TEXT,
|
||||
discovered_at INTEGER
|
||||
)""");
|
||||
|
||||
// Lots table (populated by external scraper)
|
||||
handle.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lots (
|
||||
lot_id TEXT PRIMARY KEY,
|
||||
auction_id TEXT,
|
||||
url TEXT UNIQUE,
|
||||
title TEXT,
|
||||
current_bid TEXT,
|
||||
bid_count INTEGER,
|
||||
closing_time TEXT,
|
||||
viewing_time TEXT,
|
||||
pickup_date TEXT,
|
||||
location TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
scraped_at TEXT,
|
||||
sale_id INTEGER,
|
||||
manufacturer TEXT,
|
||||
type TEXT,
|
||||
year INTEGER,
|
||||
currency TEXT DEFAULT 'EUR',
|
||||
closing_notified INTEGER DEFAULT 0,
|
||||
starting_bid TEXT,
|
||||
minimum_bid TEXT,
|
||||
status TEXT,
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
attributes_json TEXT,
|
||||
first_bid_time TEXT,
|
||||
last_bid_time TEXT,
|
||||
bid_velocity REAL,
|
||||
bid_increment REAL,
|
||||
year_manufactured INTEGER,
|
||||
condition_score REAL,
|
||||
condition_description TEXT,
|
||||
serial_number TEXT,
|
||||
damage_description TEXT,
|
||||
followers_count INTEGER DEFAULT 0,
|
||||
estimated_min_price REAL,
|
||||
estimated_max_price REAL,
|
||||
lot_condition TEXT,
|
||||
appearance TEXT,
|
||||
estimated_min REAL,
|
||||
estimated_max REAL,
|
||||
next_bid_step_cents INTEGER,
|
||||
condition TEXT,
|
||||
category_path TEXT,
|
||||
city_location TEXT,
|
||||
country_code TEXT,
|
||||
bidding_status TEXT,
|
||||
packaging TEXT,
|
||||
quantity INTEGER,
|
||||
vat REAL,
|
||||
buyer_premium_percentage REAL,
|
||||
remarks TEXT,
|
||||
reserve_price REAL,
|
||||
reserve_met INTEGER,
|
||||
view_count INTEGER,
|
||||
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id)
|
||||
)""");
|
||||
|
||||
// Images table (populated by external scraper with URLs and local_path)
|
||||
handle.execute("""
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT,
|
||||
url TEXT,
|
||||
local_path TEXT,
|
||||
downloaded INTEGER DEFAULT 0,
|
||||
labels TEXT,
|
||||
processed_at INTEGER,
|
||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
||||
)""");
|
||||
|
||||
// Bid history table
|
||||
handle.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bid_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT NOT NULL,
|
||||
bid_amount REAL NOT NULL,
|
||||
bid_time TEXT NOT NULL,
|
||||
is_autobid INTEGER DEFAULT 0,
|
||||
bidder_id TEXT,
|
||||
bidder_number INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
||||
)""");
|
||||
}
|
||||
|
||||
private void createIndexes(org.jdbi.v3.core.Handle handle) {
|
||||
handle.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
|
||||
handle.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
|
||||
handle.execute("CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id)");
|
||||
handle.execute("CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id)");
|
||||
handle.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_lot_url ON images(lot_id, url)");
|
||||
handle.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_lot_time ON bid_history(lot_id, bid_time)");
|
||||
handle.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
|
||||
}
|
||||
}
|
||||
137
src/main/java/auctiora/db/ImageRepository.java
Normal file
137
src/main/java/auctiora/db/ImageRepository.java
Normal file
@@ -0,0 +1,137 @@
|
||||
package auctiora.db;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jdbi.v3.core.Jdbi;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository for image-related database operations using JDBI3.
|
||||
* Handles image storage, object detection labels, and processing status.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ImageRepository {
|
||||
|
||||
private final Jdbi jdbi;
|
||||
|
||||
/**
|
||||
* Image record containing all image metadata.
|
||||
*/
|
||||
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) {}
|
||||
|
||||
/**
|
||||
* Minimal record for images needing object detection processing.
|
||||
*/
|
||||
public record ImageDetectionRecord(int id, long lotId, String filePath) {}
|
||||
|
||||
/**
|
||||
* Inserts a complete image record (for testing/legacy compatibility).
|
||||
* In production, scraper inserts with local_path, monitor updates labels via updateLabels.
|
||||
*/
|
||||
public void insert(long lotId, String url, String filePath, List<String> labels) {
|
||||
jdbi.useHandle(handle ->
|
||||
handle.createUpdate("""
|
||||
INSERT INTO images (lot_id, url, local_path, labels, processed_at, downloaded)
|
||||
VALUES (:lotId, :url, :localPath, :labels, :processedAt, 1)
|
||||
""")
|
||||
.bind("lotId", lotId)
|
||||
.bind("url", url)
|
||||
.bind("localPath", filePath)
|
||||
.bind("labels", String.join(",", labels))
|
||||
.bind("processedAt", Instant.now().getEpochSecond())
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the labels field for an image after object detection.
|
||||
*/
|
||||
public void updateLabels(int imageId, List<String> labels) {
|
||||
jdbi.useHandle(handle ->
|
||||
handle.createUpdate("UPDATE images SET labels = :labels, processed_at = :processedAt WHERE id = :id")
|
||||
.bind("labels", String.join(",", labels))
|
||||
.bind("processedAt", Instant.now().getEpochSecond())
|
||||
.bind("id", imageId)
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the labels for a specific image.
|
||||
*/
|
||||
public List<String> getLabels(int imageId) {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("SELECT labels FROM images WHERE id = :id")
|
||||
.bind("id", imageId)
|
||||
.mapTo(String.class)
|
||||
.findOne()
|
||||
.map(labelsStr -> {
|
||||
if (labelsStr != null && !labelsStr.isEmpty()) {
|
||||
return List.of(labelsStr.split(","));
|
||||
}
|
||||
return List.<String>of();
|
||||
})
|
||||
.orElse(List.of())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves images for a specific lot.
|
||||
*/
|
||||
public List<ImageRecord> getImagesForLot(long lotId) {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("SELECT id, lot_id, url, local_path, labels FROM images WHERE lot_id = :lotId")
|
||||
.bind("lotId", lotId)
|
||||
.map((rs, ctx) -> new ImageRecord(
|
||||
rs.getInt("id"),
|
||||
rs.getLong("lot_id"),
|
||||
rs.getString("url"),
|
||||
rs.getString("local_path"),
|
||||
rs.getString("labels")
|
||||
))
|
||||
.list()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets images that have been downloaded by the scraper but need object detection.
|
||||
* Only returns images that have local_path set but no labels yet.
|
||||
*/
|
||||
public List<ImageDetectionRecord> getImagesNeedingDetection() {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("""
|
||||
SELECT i.id, i.lot_id, i.local_path
|
||||
FROM images i
|
||||
WHERE i.local_path IS NOT NULL
|
||||
AND i.local_path != ''
|
||||
AND (i.labels IS NULL OR i.labels = '')
|
||||
""")
|
||||
.map((rs, ctx) -> {
|
||||
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
|
||||
String lotIdStr = rs.getString("lot_id");
|
||||
long lotId = auctiora.ScraperDataAdapter.extractNumericId(lotIdStr);
|
||||
|
||||
return new ImageDetectionRecord(
|
||||
rs.getInt("id"),
|
||||
lotId,
|
||||
rs.getString("local_path")
|
||||
);
|
||||
})
|
||||
.list()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total number of images in the database.
|
||||
*/
|
||||
public int getImageCount() {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("SELECT COUNT(*) FROM images")
|
||||
.mapTo(Integer.class)
|
||||
.one()
|
||||
);
|
||||
}
|
||||
}
|
||||
275
src/main/java/auctiora/db/LotRepository.java
Normal file
275
src/main/java/auctiora/db/LotRepository.java
Normal file
@@ -0,0 +1,275 @@
|
||||
package auctiora.db;
|
||||
|
||||
import auctiora.Lot;
|
||||
import auctiora.BidHistory;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jdbi.v3.core.Jdbi;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static java.sql.Types.*;
|
||||
|
||||
/**
|
||||
* Repository for lot-related database operations using JDBI3.
|
||||
* Handles CRUD operations and queries for auction lots.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class LotRepository {
|
||||
|
||||
private final Jdbi jdbi;
|
||||
|
||||
/**
|
||||
* Inserts or updates a lot (upsert operation).
|
||||
* First tries UPDATE, then falls back to INSERT if lot doesn't exist.
|
||||
*/
|
||||
public void upsert(Lot lot) {
|
||||
jdbi.useTransaction(handle -> {
|
||||
// Try UPDATE first
|
||||
int updated = handle.createUpdate("""
|
||||
UPDATE lots SET
|
||||
sale_id = :saleId,
|
||||
auction_id = :auctionId,
|
||||
title = :title,
|
||||
description = :description,
|
||||
manufacturer = :manufacturer,
|
||||
type = :type,
|
||||
year = :year,
|
||||
category = :category,
|
||||
current_bid = :currentBid,
|
||||
currency = :currency,
|
||||
url = :url,
|
||||
closing_time = :closingTime
|
||||
WHERE lot_id = :lotId
|
||||
""")
|
||||
.bind("saleId", String.valueOf(lot.saleId()))
|
||||
.bind("auctionId", String.valueOf(lot.saleId())) // auction_id = sale_id
|
||||
.bind("title", lot.title())
|
||||
.bind("description", lot.description())
|
||||
.bind("manufacturer", lot.manufacturer())
|
||||
.bind("type", lot.type())
|
||||
.bind("year", lot.year())
|
||||
.bind("category", lot.category())
|
||||
.bind("currentBid", lot.currentBid())
|
||||
.bind("currency", lot.currency())
|
||||
.bind("url", lot.url())
|
||||
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
|
||||
.bind("lotId", String.valueOf(lot.lotId()))
|
||||
.execute();
|
||||
|
||||
if (updated == 0) {
|
||||
// No rows updated, perform INSERT
|
||||
handle.createUpdate("""
|
||||
INSERT OR IGNORE INTO lots (
|
||||
lot_id, sale_id, auction_id, title, description, manufacturer, type, year,
|
||||
category, current_bid, currency, url, closing_time, closing_notified
|
||||
) VALUES (
|
||||
:lotId, :saleId, :auctionId, :title, :description, :manufacturer, :type, :year,
|
||||
:category, :currentBid, :currency, :url, :closingTime, :closingNotified
|
||||
)
|
||||
""")
|
||||
.bind("lotId", String.valueOf(lot.lotId()))
|
||||
.bind("saleId", String.valueOf(lot.saleId()))
|
||||
.bind("auctionId", String.valueOf(lot.saleId())) // auction_id = sale_id
|
||||
.bind("title", lot.title())
|
||||
.bind("description", lot.description())
|
||||
.bind("manufacturer", lot.manufacturer())
|
||||
.bind("type", lot.type())
|
||||
.bind("year", lot.year())
|
||||
.bind("category", lot.category())
|
||||
.bind("currentBid", lot.currentBid())
|
||||
.bind("currency", lot.currency())
|
||||
.bind("url", lot.url())
|
||||
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
|
||||
.bind("closingNotified", lot.closingNotified() ? 1 : 0)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a lot with full intelligence data from GraphQL enrichment.
|
||||
* Includes all 24+ intelligence fields from bidding platform.
|
||||
*/
|
||||
public void upsertWithIntelligence(Lot lot) {
|
||||
jdbi.useHandle(handle -> {
|
||||
var update = handle.createUpdate("""
|
||||
UPDATE lots SET
|
||||
sale_id = :saleId,
|
||||
title = :title,
|
||||
description = :description,
|
||||
manufacturer = :manufacturer,
|
||||
type = :type,
|
||||
year = :year,
|
||||
category = :category,
|
||||
current_bid = :currentBid,
|
||||
currency = :currency,
|
||||
url = :url,
|
||||
closing_time = :closingTime,
|
||||
followers_count = :followersCount,
|
||||
estimated_min = :estimatedMin,
|
||||
estimated_max = :estimatedMax,
|
||||
next_bid_step_cents = :nextBidStepInCents,
|
||||
condition = :condition,
|
||||
category_path = :categoryPath,
|
||||
city_location = :cityLocation,
|
||||
country_code = :countryCode,
|
||||
bidding_status = :biddingStatus,
|
||||
appearance = :appearance,
|
||||
packaging = :packaging,
|
||||
quantity = :quantity,
|
||||
vat = :vat,
|
||||
buyer_premium_percentage = :buyerPremiumPercentage,
|
||||
remarks = :remarks,
|
||||
starting_bid = :startingBid,
|
||||
reserve_price = :reservePrice,
|
||||
reserve_met = :reserveMet,
|
||||
bid_increment = :bidIncrement,
|
||||
view_count = :viewCount,
|
||||
first_bid_time = :firstBidTime,
|
||||
last_bid_time = :lastBidTime,
|
||||
bid_velocity = :bidVelocity
|
||||
WHERE lot_id = :lotId
|
||||
""")
|
||||
.bind("saleId", lot.saleId())
|
||||
.bind("title", lot.title())
|
||||
.bind("description", lot.description())
|
||||
.bind("manufacturer", lot.manufacturer())
|
||||
.bind("type", lot.type())
|
||||
.bind("year", lot.year())
|
||||
.bind("category", lot.category())
|
||||
.bind("currentBid", lot.currentBid())
|
||||
.bind("currency", lot.currency())
|
||||
.bind("url", lot.url())
|
||||
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
|
||||
.bind("followersCount", lot.followersCount())
|
||||
.bind("estimatedMin", lot.estimatedMin())
|
||||
.bind("estimatedMax", lot.estimatedMax())
|
||||
.bind("nextBidStepInCents", lot.nextBidStepInCents())
|
||||
.bind("condition", lot.condition())
|
||||
.bind("categoryPath", lot.categoryPath())
|
||||
.bind("cityLocation", lot.cityLocation())
|
||||
.bind("countryCode", lot.countryCode())
|
||||
.bind("biddingStatus", lot.biddingStatus())
|
||||
.bind("appearance", lot.appearance())
|
||||
.bind("packaging", lot.packaging())
|
||||
.bind("quantity", lot.quantity())
|
||||
.bind("vat", lot.vat())
|
||||
.bind("buyerPremiumPercentage", lot.buyerPremiumPercentage())
|
||||
.bind("remarks", lot.remarks())
|
||||
.bind("startingBid", lot.startingBid())
|
||||
.bind("reservePrice", lot.reservePrice())
|
||||
.bind("reserveMet", lot.reserveMet() != null && lot.reserveMet() ? 1 : null)
|
||||
.bind("bidIncrement", lot.bidIncrement())
|
||||
.bind("viewCount", lot.viewCount())
|
||||
.bind("firstBidTime", lot.firstBidTime() != null ? lot.firstBidTime().toString() : null)
|
||||
.bind("lastBidTime", lot.lastBidTime() != null ? lot.lastBidTime().toString() : null)
|
||||
.bind("bidVelocity", lot.bidVelocity())
|
||||
.bind("lotId", lot.lotId());
|
||||
|
||||
int updated = update.execute();
|
||||
if (updated == 0) {
|
||||
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates only the current bid for a lot (lightweight update).
|
||||
*/
|
||||
public void updateCurrentBid(Lot lot) {
|
||||
jdbi.useHandle(handle ->
|
||||
handle.createUpdate("UPDATE lots SET current_bid = :bid WHERE lot_id = :lotId")
|
||||
.bind("bid", lot.currentBid())
|
||||
.bind("lotId", String.valueOf(lot.lotId()))
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates notification flags for a lot.
|
||||
*/
|
||||
public void updateNotificationFlags(Lot lot) {
|
||||
jdbi.useHandle(handle ->
|
||||
handle.createUpdate("UPDATE lots SET closing_notified = :notified WHERE lot_id = :lotId")
|
||||
.bind("notified", lot.closingNotified() ? 1 : 0)
|
||||
.bind("lotId", String.valueOf(lot.lotId()))
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all active lots.
|
||||
* Note: Despite the name, this returns ALL lots (legacy behavior for backward compatibility).
|
||||
*/
|
||||
public List<Lot> getActiveLots() {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("SELECT * FROM lots")
|
||||
.map((rs, ctx) -> auctiora.ScraperDataAdapter.fromScraperLot(rs))
|
||||
.list()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all lots from the database.
|
||||
*/
|
||||
public List<Lot> getAllLots() {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("SELECT * FROM lots")
|
||||
.map((rs, ctx) -> auctiora.ScraperDataAdapter.fromScraperLot(rs))
|
||||
.list()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves bid history for a specific lot.
|
||||
*/
|
||||
public List<BidHistory> getBidHistory(String lotId) {
|
||||
return jdbi.withHandle(handle ->
|
||||
handle.createQuery("""
|
||||
SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number
|
||||
FROM bid_history
|
||||
WHERE lot_id = :lotId
|
||||
ORDER BY bid_time DESC
|
||||
""")
|
||||
.bind("lotId", lotId)
|
||||
.map((rs, ctx) -> new BidHistory(
|
||||
rs.getInt("id"),
|
||||
rs.getString("lot_id"),
|
||||
rs.getDouble("bid_amount"),
|
||||
LocalDateTime.parse(rs.getString("bid_time")),
|
||||
rs.getInt("is_autobid") != 0,
|
||||
rs.getString("bidder_id"),
|
||||
(Integer) rs.getObject("bidder_number")
|
||||
))
|
||||
.list()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts bid history records in batch.
|
||||
*/
|
||||
public void insertBidHistory(List<BidHistory> bidHistory) {
|
||||
jdbi.useHandle(handle -> {
|
||||
var batch = handle.prepareBatch("""
|
||||
INSERT OR IGNORE INTO bid_history (
|
||||
lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number
|
||||
) VALUES (:lotId, :bidAmount, :bidTime, :isAutobid, :bidderId, :bidderNumber)
|
||||
""");
|
||||
|
||||
bidHistory.forEach(bid ->
|
||||
batch.bind("lotId", bid.lotId())
|
||||
.bind("bidAmount", bid.bidAmount())
|
||||
.bind("bidTime", bid.bidTime().toString())
|
||||
.bind("isAutobid", bid.isAutobid() ? 1 : 0)
|
||||
.bind("bidderId", bid.bidderId())
|
||||
.bind("bidderNumber", bid.bidderNumber())
|
||||
.add()
|
||||
);
|
||||
|
||||
batch.execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,12 @@ quarkus.rest.path=/
|
||||
quarkus.http.root-path=/
|
||||
|
||||
# Auction Monitor Configuration
|
||||
auction.database.path=C:\\mnt\\okcomputer\\output\\cache.db
|
||||
auction.images.path=C:\\mnt\\okcomputer\\output\\images
|
||||
auction.database.path=/mnt/okcomputer/output/cache.db
|
||||
auction.images.path=/mnt/okcomputer/output/images
|
||||
auction.notification.config=desktop
|
||||
auction.yolo.config=models/yolov4.cfg
|
||||
auction.yolo.weights=models/yolov4.weights
|
||||
auction.yolo.classes=models/coco.names
|
||||
auction.yolo.config=/mnt/okcomputer/output/models/yolov4.cfg
|
||||
auction.yolo.weights=/mnt/okcomputer/output/models/yolov4.weights
|
||||
auction.yolo.classes=/mnt/okcomputer/output/models/coco.names
|
||||
|
||||
# Scheduler Configuration
|
||||
quarkus.scheduler.enabled=true
|
||||
|
||||
138
src/test/java/auctiora/ClosingTimeCalculationTest.java
Normal file
138
src/test/java/auctiora/ClosingTimeCalculationTest.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package auctiora;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests for closing time calculations that power the UI
|
||||
* Tests the minutesUntilClose() logic used in dashboard and alerts
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
@DisplayName("Closing Time Calculation Tests")
|
||||
class ClosingTimeCalculationTest {
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("Should calculate minutes until close for lot closing in 15 minutes")
|
||||
void testMinutesUntilClose15Minutes() {
|
||||
var lot = createLot(LocalDateTime.now().plusMinutes(15));
|
||||
long minutes = lot.minutesUntilClose();
|
||||
|
||||
assertTrue(minutes >= 14 && minutes <= 16,
|
||||
"Should be approximately 15 minutes, was: " + minutes);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("Should calculate minutes until close for lot closing in 2 hours")
|
||||
void testMinutesUntilClose2Hours() {
|
||||
var lot = createLot(LocalDateTime.now().plusHours(2));
|
||||
long minutes = lot.minutesUntilClose();
|
||||
|
||||
assertTrue(minutes >= 119 && minutes <= 121,
|
||||
"Should be approximately 120 minutes, was: " + minutes);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
@DisplayName("Should return negative value for already closed lot")
|
||||
void testMinutesUntilCloseNegative() {
|
||||
var lot = createLot(LocalDateTime.now().minusHours(1));
|
||||
long minutes = lot.minutesUntilClose();
|
||||
|
||||
assertTrue(minutes < 0,
|
||||
"Should be negative for closed lots, was: " + minutes);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
@DisplayName("Should return MAX_VALUE when lot has no closing time")
|
||||
void testMinutesUntilCloseNoTime() {
|
||||
var lot = Lot.basic(100, 1001, "No closing time", "", "", "", 0, "General",
|
||||
100.0, "EUR", "http://test.com/1001", null, false);
|
||||
long minutes = lot.minutesUntilClose();
|
||||
|
||||
assertEquals(Long.MAX_VALUE, minutes,
|
||||
"Should return MAX_VALUE when no closing time set");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
@DisplayName("Should identify lots closing within 5 minutes (critical threshold)")
|
||||
void testCriticalClosingThreshold() {
|
||||
var closing4Min = createLot(LocalDateTime.now().plusMinutes(4));
|
||||
var closing5Min = createLot(LocalDateTime.now().plusMinutes(5));
|
||||
var closing6Min = createLot(LocalDateTime.now().plusMinutes(6));
|
||||
|
||||
assertTrue(closing4Min.minutesUntilClose() < 5,
|
||||
"Lot closing in 4 min should be < 5 minutes");
|
||||
assertTrue(closing5Min.minutesUntilClose() >= 5,
|
||||
"Lot closing in 5 min should be >= 5 minutes");
|
||||
assertTrue(closing6Min.minutesUntilClose() > 5,
|
||||
"Lot closing in 6 min should be > 5 minutes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
@DisplayName("Should identify lots closing within 30 minutes (dashboard threshold)")
|
||||
void testDashboardClosingThreshold() {
|
||||
var closing20Min = createLot(LocalDateTime.now().plusMinutes(20));
|
||||
var 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);
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ class DatabaseServiceTest {
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
} catch (Exception e) {
|
||||
fail("Thread 1 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
@@ -370,7 +370,7 @@ class DatabaseServiceTest {
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
} catch (Exception e) {
|
||||
fail("Thread 2 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,13 +72,13 @@ class ImageProcessingServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle database error gracefully")
|
||||
void testProcessImageDatabaseError() throws SQLException {
|
||||
void testProcessImageDatabaseError() {
|
||||
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||
|
||||
when(mockDetector.detectObjects(normalizedPath))
|
||||
.thenReturn(List.of("object"));
|
||||
|
||||
doThrow(new SQLException("Database error"))
|
||||
doThrow(new RuntimeException("Database error"))
|
||||
.when(mockDb).updateImageLabels(anyInt(), anyList());
|
||||
|
||||
// Should return false on error
|
||||
@@ -162,9 +162,9 @@ class ImageProcessingServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle database query error in batch processing")
|
||||
void testProcessPendingImagesDatabaseError() throws SQLException {
|
||||
void testProcessPendingImagesDatabaseError() {
|
||||
when(mockDb.getImagesNeedingDetection())
|
||||
.thenThrow(new SQLException("Database connection failed"));
|
||||
.thenThrow(new RuntimeException("Database connection failed"));
|
||||
|
||||
// Should not throw exception
|
||||
assertDoesNotThrow(() -> service.processPendingImages());
|
||||
|
||||
@@ -370,11 +370,11 @@ class IntegrationTest {
|
||||
"https://example.com/60" + i, "A1", 5, null
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
} catch (Exception e) {
|
||||
fail("Auction thread failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
var lotThread = new Thread(() -> {
|
||||
try {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
@@ -383,7 +383,7 @@ class IntegrationTest {
|
||||
100.0 * i, "EUR", "https://example.com/70" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
} catch (Exception e) {
|
||||
fail("Lot thread failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ class ObjectDetectionServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should initialize with missing YOLO models (disabled mode)")
|
||||
void testInitializeWithoutModels() throws IOException {
|
||||
// When models don't exist, service should initialize in disabled mode
|
||||
ObjectDetectionService service = new ObjectDetectionService(
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
@@ -84,7 +83,6 @@ class ObjectDetectionServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should throw IOException when model files exist but OpenCV fails to load")
|
||||
void testInitializeWithValidModels() throws IOException {
|
||||
// Create dummy model files for testing initialization
|
||||
var cfgPath = Paths.get(TEST_CFG);
|
||||
var weightsPath = Paths.get(TEST_WEIGHTS);
|
||||
var classesPath = Paths.get(TEST_CLASSES);
|
||||
|
||||
@@ -16,366 +16,365 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TroostwijkMonitorTest {
|
||||
|
||||
private String testDbPath;
|
||||
private TroostwijkMonitor monitor;
|
||||
|
||||
@BeforeAll
|
||||
void setUp() throws SQLException, IOException {
|
||||
testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db";
|
||||
|
||||
// Initialize with non-existent YOLO models (disabled mode)
|
||||
monitor = new TroostwijkMonitor(
|
||||
testDbPath,
|
||||
"desktop",
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void tearDown() throws Exception {
|
||||
Files.deleteIfExists(Paths.get(testDbPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize monitor successfully")
|
||||
void testMonitorInitialization() {
|
||||
assertNotNull(monitor);
|
||||
assertNotNull(monitor.getDb());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should print database stats without error")
|
||||
void testPrintDatabaseStats() {
|
||||
assertDoesNotThrow(() -> monitor.printDatabaseStats());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process pending images without error")
|
||||
void testProcessPendingImages() {
|
||||
assertDoesNotThrow(() -> monitor.processPendingImages());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty database gracefully")
|
||||
void testEmptyDatabaseHandling() throws SQLException {
|
||||
var auctions = monitor.getDb().getAllAuctions();
|
||||
var lots = monitor.getDb().getAllLots();
|
||||
|
||||
assertNotNull(auctions);
|
||||
assertNotNull(lots);
|
||||
assertTrue(auctions.isEmpty() || auctions.size() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should track lots in database")
|
||||
void testLotTracking() throws SQLException {
|
||||
// Insert test lot
|
||||
var lot = Lot.basic(
|
||||
11111, 22222,
|
||||
"Test Forklift",
|
||||
"Electric forklift in good condition",
|
||||
"Toyota",
|
||||
"Electric",
|
||||
2020,
|
||||
"Machinery",
|
||||
1500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/22222",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
var lots = monitor.getDb().getAllLots();
|
||||
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 22222));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should monitor lots closing soon")
|
||||
void testClosingSoonMonitoring() throws SQLException {
|
||||
// Insert lot closing in 4 minutes
|
||||
var closingSoon = Lot.basic(
|
||||
33333, 44444,
|
||||
"Closing Soon Item",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/44444",
|
||||
LocalDateTime.now().plusMinutes(4),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(closingSoon);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 44444)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.minutesUntilClose() < 30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should identify lots with time remaining")
|
||||
void testTimeRemainingCalculation() throws SQLException {
|
||||
var futureLot = Lot.basic(
|
||||
55555, 66666,
|
||||
"Future Lot",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
200.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/66666",
|
||||
LocalDateTime.now().plusHours(2),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(futureLot);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 66666)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.minutesUntilClose() > 60);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle lots without closing time")
|
||||
void testLotsWithoutClosingTime() throws SQLException {
|
||||
var noClosing = Lot.basic(
|
||||
77777, 88888,
|
||||
"No Closing Time",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
150.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/88888",
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(noClosing);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 88888)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertNull(found.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should track notification status")
|
||||
void testNotificationStatusTracking() throws SQLException {
|
||||
var lot = Lot.basic(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/11110",
|
||||
LocalDateTime.now().plusMinutes(3),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Update notification flag
|
||||
var notified = Lot.basic(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/11110",
|
||||
LocalDateTime.now().plusMinutes(3),
|
||||
true
|
||||
);
|
||||
|
||||
monitor.getDb().updateLotNotificationFlags(notified);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 11110)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update bid amounts")
|
||||
void testBidAmountUpdates() throws SQLException {
|
||||
var lot = Lot.basic(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/13131",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Simulate bid increase
|
||||
var higherBid = Lot.basic(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
250.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/13131",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().updateLotCurrentBid(higherBid);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 13131)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertEquals(250.00, found.currentBid(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple concurrent lot updates")
|
||||
void testConcurrentLotUpdates() throws InterruptedException, SQLException {
|
||||
Thread t1 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
monitor.getDb().upsertLot(Lot.basic(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Thread 1 failed: " + e.getMessage());
|
||||
|
||||
private String testDbPath;
|
||||
private TroostwijkMonitor monitor;
|
||||
|
||||
@BeforeAll
|
||||
void setUp() throws SQLException, IOException {
|
||||
testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db";
|
||||
|
||||
monitor = new TroostwijkMonitor(
|
||||
testDbPath,
|
||||
"desktop",
|
||||
"non_existent.cfg",
|
||||
"non_existent.weights",
|
||||
"non_existent.txt"
|
||||
);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void tearDown() throws Exception {
|
||||
Files.deleteIfExists(Paths.get(testDbPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should initialize monitor successfully")
|
||||
void testMonitorInitialization() {
|
||||
assertNotNull(monitor);
|
||||
assertNotNull(monitor.getDb());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should print database stats without error")
|
||||
void testPrintDatabaseStats() {
|
||||
assertDoesNotThrow(() -> monitor.printDatabaseStats());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process pending images without error")
|
||||
void testProcessPendingImages() {
|
||||
assertDoesNotThrow(() -> monitor.processPendingImages());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty database gracefully")
|
||||
void testEmptyDatabaseHandling() throws SQLException {
|
||||
var auctions = monitor.getDb().getAllAuctions();
|
||||
var lots = monitor.getDb().getAllLots();
|
||||
|
||||
assertNotNull(auctions);
|
||||
assertNotNull(lots);
|
||||
assertTrue(auctions.isEmpty() || auctions.size() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should track lots in database")
|
||||
void testLotTracking() throws SQLException {
|
||||
// Insert test lot
|
||||
var lot = Lot.basic(
|
||||
11111, 22222,
|
||||
"Test Forklift",
|
||||
"Electric forklift in good condition",
|
||||
"Toyota",
|
||||
"Electric",
|
||||
2020,
|
||||
"Machinery",
|
||||
1500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/22222",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
var lots = monitor.getDb().getAllLots();
|
||||
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 22222));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should monitor lots closing soon")
|
||||
void testClosingSoonMonitoring() throws SQLException {
|
||||
// Insert lot closing in 4 minutes
|
||||
var closingSoon = Lot.basic(
|
||||
33333, 44444,
|
||||
"Closing Soon Item",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/44444",
|
||||
LocalDateTime.now().plusMinutes(4),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(closingSoon);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 44444)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.minutesUntilClose() < 30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should identify lots with time remaining")
|
||||
void testTimeRemainingCalculation() throws SQLException {
|
||||
var futureLot = Lot.basic(
|
||||
55555, 66666,
|
||||
"Future Lot",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
200.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/66666",
|
||||
LocalDateTime.now().plusHours(2),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(futureLot);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 66666)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.minutesUntilClose() > 60);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle lots without closing time")
|
||||
void testLotsWithoutClosingTime() throws SQLException {
|
||||
var noClosing = Lot.basic(
|
||||
77777, 88888,
|
||||
"No Closing Time",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
150.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/88888",
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(noClosing);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 88888)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertNull(found.closingTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should track notification status")
|
||||
void testNotificationStatusTracking() throws SQLException {
|
||||
var lot = Lot.basic(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/11110",
|
||||
LocalDateTime.now().plusMinutes(3),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Update notification flag
|
||||
var notified = Lot.basic(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/11110",
|
||||
LocalDateTime.now().plusMinutes(3),
|
||||
true
|
||||
);
|
||||
|
||||
monitor.getDb().updateLotNotificationFlags(notified);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 11110)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertTrue(found.closingNotified());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update bid amounts")
|
||||
void testBidAmountUpdates() throws SQLException {
|
||||
var lot = Lot.basic(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
100.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/13131",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Simulate bid increase
|
||||
var higherBid = Lot.basic(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
250.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/13131",
|
||||
LocalDateTime.now().plusDays(1),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().updateLotCurrentBid(higherBid);
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
var found = lots.stream()
|
||||
.filter(l -> l.lotId() == 13131)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(found);
|
||||
assertEquals(250.00, found.currentBid(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple concurrent lot updates")
|
||||
void testConcurrentLotUpdates() throws InterruptedException, SQLException {
|
||||
Thread t1 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
monitor.getDb().upsertLot(Lot.basic(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
Thread t2 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 5; i < 10; i++) {
|
||||
monitor.getDb().upsertLot(Lot.basic(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
fail("Thread 2 failed: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
fail("Thread 1 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
Thread t2 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 5; i < 10; i++) {
|
||||
monitor.getDb().upsertLot(Lot.basic(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
t1.start();
|
||||
t2.start();
|
||||
t1.join();
|
||||
t2.join();
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
long count = lots.stream()
|
||||
.filter(l -> l.lotId() >= 30000 && l.lotId() < 30010)
|
||||
.count();
|
||||
|
||||
assertTrue(count >= 10);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should schedule monitoring without error")
|
||||
void testScheduleMonitoring() {
|
||||
// This just tests that scheduling doesn't throw
|
||||
// Actual monitoring would run in background
|
||||
assertDoesNotThrow(() -> {
|
||||
// Don't actually start monitoring in test
|
||||
// Just verify monitor is ready
|
||||
assertNotNull(monitor);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle database with auctions and lots")
|
||||
void testDatabaseWithData() throws SQLException {
|
||||
// Insert auction
|
||||
var auction = new AuctionInfo(
|
||||
40000,
|
||||
"Test Auction",
|
||||
"Amsterdam, NL",
|
||||
"Amsterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/40000",
|
||||
"A7",
|
||||
10,
|
||||
LocalDateTime.now().plusDays(2)
|
||||
);
|
||||
|
||||
monitor.getDb().upsertAuction(auction);
|
||||
|
||||
// Insert related lot
|
||||
var lot = Lot.basic(
|
||||
40000, 50000,
|
||||
"Test Lot",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/50000",
|
||||
LocalDateTime.now().plusDays(2),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Verify
|
||||
var auctions = monitor.getDb().getAllAuctions();
|
||||
var lots = monitor.getDb().getAllLots();
|
||||
|
||||
assertTrue(auctions.stream().anyMatch(a -> a.auctionId() == 40000));
|
||||
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 50000));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("Thread 2 failed: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
t1.start();
|
||||
t2.start();
|
||||
t1.join();
|
||||
t2.join();
|
||||
|
||||
var lots = monitor.getDb().getActiveLots();
|
||||
long count = lots.stream()
|
||||
.filter(l -> l.lotId() >= 30000 && l.lotId() < 30010)
|
||||
.count();
|
||||
|
||||
assertTrue(count >= 10);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should schedule monitoring without error")
|
||||
void testScheduleMonitoring() {
|
||||
// This just tests that scheduling doesn't throw
|
||||
// Actual monitoring would run in background
|
||||
assertDoesNotThrow(() -> {
|
||||
// Don't actually start monitoring in test
|
||||
// Just verify monitor is ready
|
||||
assertNotNull(monitor);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle database with auctions and lots")
|
||||
void testDatabaseWithData() throws SQLException {
|
||||
// Insert auction
|
||||
var auction = new AuctionInfo(
|
||||
40000,
|
||||
"Test Auction",
|
||||
"Amsterdam, NL",
|
||||
"Amsterdam",
|
||||
"NL",
|
||||
"https://example.com/auction/40000",
|
||||
"A7",
|
||||
10,
|
||||
LocalDateTime.now().plusDays(2)
|
||||
);
|
||||
|
||||
monitor.getDb().upsertAuction(auction);
|
||||
|
||||
// Insert related lot
|
||||
var lot = Lot.basic(
|
||||
40000, 50000,
|
||||
"Test Lot",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"Category",
|
||||
500.00,
|
||||
"EUR",
|
||||
"https://example.com/lot/50000",
|
||||
LocalDateTime.now().plusDays(2),
|
||||
false
|
||||
);
|
||||
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Verify
|
||||
var auctions = monitor.getDb().getAllAuctions();
|
||||
var lots = monitor.getDb().getAllLots();
|
||||
|
||||
assertTrue(auctions.stream().anyMatch(a -> a.auctionId() == 40000));
|
||||
assertTrue(lots.stream().anyMatch(l -> l.lotId() == 50000));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user