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) } }