Files
AetherForge/server/internal/maintenance/retention_test.go
AetherForge feba06e008 Improve portable launch, forge persistence, and operator auth UX.
Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
2026-05-31 18:56:43 -07:00

346 lines
9.1 KiB
Go

package maintenance
import (
"bytes"
"database/sql"
"errors"
"log"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
func openTestDB(t *testing.T) *db.Database {
t.Helper()
d, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
return d
}
func insertBuild(t *testing.T, d *db.Database, b *models.BuildRecord) {
t.Helper()
if err := d.InsertBuild(b); err != nil {
t.Fatal(err)
}
}
func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Time, hashrate float64) {
t.Helper()
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)",
agentID, hashrate, ts)
if err != nil {
t.Fatal(err)
}
}
func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
d := openTestDB(t)
t.Cleanup(StopRetentionJobs)
StartRetentionJobs(d, t.TempDir(), 0, 0)
// Disabled config must not start a goroutine that mutates data.
time.Sleep(20 * time.Millisecond)
}
func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
t.Cleanup(StopRetentionJobs)
StartRetentionJobs(d, t.TempDir(), 24, 0)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
if err != nil {
t.Fatal(err)
}
if n == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("expected immediate retention pass to purge old hashrate samples")
}
func TestStartRetentionJobs_TickerInterval(t *testing.T) {
prev := retentionTickInterval
retentionTickInterval = 40 * time.Millisecond
t.Cleanup(func() { retentionTickInterval = prev })
d := openTestDB(t)
var passes int32
noopRetention := func(database *db.Database, dataDir string, statsHours, buildDays int) {
atomic.AddInt32(&passes, 1)
}
runRetentionFn = noopRetention
t.Cleanup(func() {
retentionTickInterval = prev
runRetentionFn = runRetention
StopRetentionJobs()
})
StartRetentionJobs(d, t.TempDir(), 1, 0)
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&passes) >= 2 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
}
func TestStopRetentionJobs_StopsBackgroundLoop(t *testing.T) {
prev := retentionTickInterval
retentionTickInterval = 40 * time.Millisecond
t.Cleanup(func() {
retentionTickInterval = prev
runRetentionFn = runRetention
StopRetentionJobs()
})
d := openTestDB(t)
var passes int32
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {
atomic.AddInt32(&passes, 1)
}
StartRetentionJobs(d, t.TempDir(), 1, 0)
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&passes) >= 2 {
break
}
time.Sleep(10 * time.Millisecond)
}
if atomic.LoadInt32(&passes) < 2 {
t.Fatalf("expected at least 2 passes before stop, got %d", passes)
}
before := atomic.LoadInt32(&passes)
StopRetentionJobs()
time.Sleep(120 * time.Millisecond)
if got := atomic.LoadInt32(&passes); got != before {
t.Fatalf("expected no retention passes after stop, before=%d after=%d", before, got)
}
}
func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
seedHashrateSample(t, d, "a1", time.Now(), 200)
runRetention(d, t.TempDir(), 24, 0)
n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("expected old sample already purged, PurgeHashrateSamplesBefore returned %d", n)
}
var remaining int
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
t.Fatal(err)
}
if remaining != 1 {
t.Fatalf("expected 1 recent sample left, got %d", remaining)
}
}
func TestRunRetention_SkipsStatsWhenZero(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
runRetention(d, t.TempDir(), 0, 0)
var remaining int
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
t.Fatal(err)
}
if remaining != 1 {
t.Fatalf("expected sample retained when statsHours=0, got %d", remaining)
}
}
func TestRunRetention_PurgesBuildWithFilePath(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
artifactDir := filepath.Join(dataDir, "artifacts", "old-build")
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
t.Fatal(err)
}
artifactFile := filepath.Join(artifactDir, "agent.exe")
if err := os.WriteFile(artifactFile, []byte("binary"), 0o644); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "old-build",
WorkerName: "worker-1",
ServerURL: "http://localhost",
Wallet: "wallet",
FilePath: artifactFile,
CreatedAt: time.Now().Add(-48 * time.Hour),
})
insertBuild(t, d, &models.BuildRecord{
ID: "new-build",
WorkerName: "worker-2",
ServerURL: "http://localhost",
Wallet: "wallet",
FilePath: filepath.Join(dataDir, "artifacts", "new-build", "agent.exe"),
CreatedAt: time.Now(),
})
runRetention(d, dataDir, 0, 1)
if _, err := os.Stat(artifactDir); !os.IsNotExist(err) {
t.Fatalf("expected artifact dir removed, stat err=%v", err)
}
_, err := d.GetBuild("old-build")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected old build deleted from db, got %v", err)
}
if _, err := d.GetBuild("new-build"); err != nil {
t.Fatalf("expected new build retained: %v", err)
}
}
func TestRunRetention_PurgesBuildWithoutFilePath(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
buildDir := filepath.Join(dataDir, "builds", "legacy-build")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(buildDir, "bundle.zip"), []byte("zip"), 0o644); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "legacy-build",
WorkerName: "worker-legacy",
ServerURL: "http://localhost",
Wallet: "wallet",
CreatedAt: time.Now().Add(-72 * time.Hour),
})
runRetention(d, dataDir, 0, 1)
if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
t.Fatalf("expected fallback build dir removed, stat err=%v", err)
}
_, err := d.GetBuild("legacy-build")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected legacy build deleted, got %v", err)
}
}
func TestRunRetention_SkipsBuildsWhenZero(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
buildDir := filepath.Join(dataDir, "builds", "keep-me")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "keep-me",
WorkerName: "worker",
ServerURL: "http://localhost",
Wallet: "wallet",
CreatedAt: time.Now().Add(-72 * time.Hour),
})
runRetention(d, dataDir, 0, 0)
if _, err := os.Stat(buildDir); err != nil {
t.Fatalf("expected build dir kept when buildDays=0: %v", err)
}
if _, err := d.GetBuild("keep-me"); err != nil {
t.Fatalf("expected build record kept: %v", err)
}
}
func TestRunRetention_HashratePurgeErrorLogged(t *testing.T) {
d := openTestDB(t)
_ = d.Close()
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })
runRetention(d, t.TempDir(), 24, 0)
if !strings.Contains(buf.String(), "[Retention] hashrate purge failed:") {
t.Fatalf("expected hashrate purge error log, got: %q", buf.String())
}
}
func TestRunRetention_BuildListErrorLogged(t *testing.T) {
d := openTestDB(t)
_ = d.Close()
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })
runRetention(d, t.TempDir(), 0, 7)
if !strings.Contains(buf.String(), "[Retention] build list failed:") {
t.Fatalf("expected build list error log, got: %q", buf.String())
}
}
func TestRunRetention_StatsAndBuildsTogether(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
buildDir := filepath.Join(dataDir, "builds", "combo-old")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "combo-old",
WorkerName: "worker",
ServerURL: "http://localhost",
Wallet: "wallet",
CreatedAt: time.Now().Add(-48 * time.Hour),
})
runRetention(d, dataDir, 24, 1)
var samples int
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&samples); err != nil {
t.Fatal(err)
}
if samples != 0 {
t.Fatalf("expected stats purged, got %d samples", samples)
}
if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
t.Fatalf("expected build dir removed, stat err=%v", err)
}
_, err := d.GetBuild("combo-old")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected build removed, got %v", err)
}
}
func TestRetentionTickIntervalDefault(t *testing.T) {
if retentionTickInterval != 6*time.Hour {
t.Fatalf("expected default tick interval 6h, got %v", retentionTickInterval)
}
}