package db import ( "database/sql" "errors" "testing" "time" "crypto-miner-server/internal/models" ) func openTestDB(t *testing.T) *Database { t.Helper() d, err := New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) return d } func seedAgent(t *testing.T, d *Database, id string) *models.Agent { t.Helper() a := &models.Agent{ ID: id, Name: "worker-" + id, Hostname: "worker-" + id, Wallet: "wallet", IP: "10.0.0.1", Version: "2.0", Status: "offline", CPUCores: 4, MemoryGB: 8, LastSeen: time.Now(), } if err := d.UpsertAgent(a); err != nil { t.Fatal(err) } return a } func TestNewCreatesDatabase(t *testing.T) { dir := t.TempDir() d, err := New(dir) if err != nil { t.Fatal(err) } defer d.Close() var n int if err := d.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'").Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("expected agents table, got count %d", n) } } func TestGetAgentNotFound(t *testing.T) { d := openTestDB(t) _, err := d.GetAgent("missing-agent") if !errors.Is(err, sql.ErrNoRows) { t.Fatalf("expected sql.ErrNoRows, got %v", err) } } func TestUpsertAgentPreservesOperatorNameWhenDiffersFromHostname(t *testing.T) { d := openTestDB(t) a := &models.Agent{ ID: "renamed-worker", Name: "Living Room PC", Hostname: "DESKTOP-ABC123", Status: "offline", LastSeen: time.Now(), } if err := d.UpsertAgent(a); err != nil { t.Fatal(err) } // Reconnect reports hostname only — UpsertAgent must keep operator label. a.Name = "DESKTOP-ABC123" a.Hostname = "DESKTOP-ABC123" a.Status = "online" if err := d.UpsertAgent(a); err != nil { t.Fatal(err) } got, err := d.GetAgent(a.ID) if err != nil { t.Fatal(err) } if got.Name != "Living Room PC" { t.Fatalf("operator name should be preserved; got %q hostname=%q", got.Name, got.Hostname) } } func TestUpsertAgentPreservesCreatedAt(t *testing.T) { d := openTestDB(t) a := seedAgent(t, d, "persist-created") first, err := d.GetAgent(a.ID) if err != nil { t.Fatal(err) } time.Sleep(10 * time.Millisecond) a.Name = "renamed" if a.Hostname == "" { a.Hostname = first.Name } a.Hostname = "renamed" a.Status = "online" if err := d.UpsertAgent(a); err != nil { t.Fatal(err) } second, err := d.GetAgent(a.ID) if err != nil { t.Fatal(err) } if !second.CreatedAt.Equal(first.CreatedAt) { t.Fatalf("created_at changed: %v -> %v", first.CreatedAt, second.CreatedAt) } if second.Name != "renamed" { t.Fatalf("name not updated: %q", second.Name) } } func TestUpdateAgentStats(t *testing.T) { d := openTestDB(t) a := seedAgent(t, d, "stats-agent") if err := d.UpdateAgentStats(a.ID, 100, 200, 300, 10, 8, 2, 55.5, 66.6, 3600); err != nil { t.Fatal(err) } got, err := d.GetAgent(a.ID) if err != nil { t.Fatal(err) } if got.Status != "online" { t.Fatalf("status: got %q want online", got.Status) } if got.Hashrate15s != 100 || got.Hashrate1m != 200 || got.Hashrate15m != 300 { t.Fatalf("hashrate mismatch: %+v", got) } if got.SharesTotal != 10 || got.SharesGood != 8 || got.SharesBad != 2 { t.Fatalf("shares mismatch: %+v", got) } if got.CPUUsagePct != 55.5 || got.MemoryUsagePct != 66.6 || got.UptimeSeconds != 3600 { t.Fatalf("usage/uptime mismatch: %+v", got) } } func TestSetAgentOffline(t *testing.T) { d := openTestDB(t) a := seedAgent(t, d, "offline-agent") if err := d.UpdateAgentStats(a.ID, 1, 1, 1, 0, 0, 0, 0, 0, 0); err != nil { t.Fatal(err) } if err := d.SetAgentOffline(a.ID); err != nil { t.Fatal(err) } got, err := d.GetAgent(a.ID) if err != nil { t.Fatal(err) } if got.Status != "offline" { t.Fatalf("status: got %q want offline", got.Status) } } func TestShareInsertUpdateAndRecent(t *testing.T) { d := openTestDB(t) seedAgent(t, d, "share-agent") ts := time.Now().UTC().Truncate(time.Second) share := &models.Share{ AgentID: "share-agent", JobID: "job-1", Difficulty: 1000, Accepted: false, Hash: "abc123", Nonce: "deadbeef", Error: "pending", Timestamp: ts, } id, err := d.InsertShare(share) if err != nil { t.Fatal(err) } if id <= 0 { t.Fatalf("expected positive insert id, got %d", id) } if err := d.UpdateShareResult(id, true, ""); err != nil { t.Fatal(err) } recent, err := d.GetRecentShares(10) if err != nil { t.Fatal(err) } if len(recent) != 1 { t.Fatalf("expected 1 share, got %d", len(recent)) } if !recent[0].Accepted { t.Fatal("share should be accepted after update") } if recent[0].Error != "" { t.Fatalf("error should be cleared, got %q", recent[0].Error) } if err := d.UpdateShareResult(id, false, "pool rejected"); err != nil { t.Fatal(err) } recent, err = d.GetRecentShares(1) if err != nil { t.Fatal(err) } if recent[0].Accepted || recent[0].Error != "pool rejected" { t.Fatalf("unexpected share after reject update: %+v", recent[0]) } } func TestHashrateSampleHistory(t *testing.T) { d := openTestDB(t) seedAgent(t, d, "hr-agent") if err := d.InsertHashrateSample("hr-agent", 150.5, 0); err != nil { t.Fatal(err) } if err := d.InsertHashrateSample("hr-agent", 200.0, 10.0); err != nil { t.Fatal(err) } history, err := d.GetHashrateHistory("hr-agent", 5) if err != nil { t.Fatal(err) } if len(history) != 2 { t.Fatalf("expected 2 samples, got %d", len(history)) } if history[0].Hashrate != 200.0 { t.Fatalf("expected newest first, got %f", history[0].Hashrate) } empty, err := d.GetHashrateHistory("unknown", 5) if err != nil { t.Fatal(err) } if len(empty) != 0 { t.Fatalf("expected empty history, got %d", len(empty)) } } func TestGetFleetStatsWithAgents(t *testing.T) { d := openTestDB(t) seedAgent(t, d, "fleet-a") seedAgent(t, d, "fleet-b") if err := d.UpdateAgentStats("fleet-a", 0, 0, 100, 20, 18, 2, 0, 0, 0); err != nil { t.Fatal(err) } if err := d.UpdateAgentStats("fleet-b", 0, 0, 50, 10, 5, 5, 0, 0, 0); err != nil { t.Fatal(err) } stats, err := d.GetFleetStats() if err != nil { t.Fatal(err) } if stats.TotalAgents != 2 { t.Fatalf("total agents: got %d want 2", stats.TotalAgents) } if stats.OnlineAgents != 2 { t.Fatalf("online agents: got %d want 2", stats.OnlineAgents) } if stats.TotalHashrate != 150 { t.Fatalf("total hashrate: got %f want 150", stats.TotalHashrate) } if stats.TotalShares != 30 || stats.AcceptedShares != 23 || stats.RejectedShares != 7 { t.Fatalf("share totals mismatch: %+v", stats) } wantRate := float64(23) / float64(30) * 100 if stats.AcceptRate != wantRate { t.Fatalf("accept rate: got %f want %f", stats.AcceptRate, wantRate) } }