diff --git a/pkg/mining/manager.go b/pkg/mining/manager.go index c969d7c..0bd462a 100644 --- a/pkg/mining/manager.go +++ b/pkg/mining/manager.go @@ -16,6 +16,8 @@ type Manager struct { waitGroup sync.WaitGroup } +var _ ManagerInterface = (*Manager)(nil) + // NewManager creates a new miner manager func NewManager() *Manager { m := &Manager{ diff --git a/pkg/mining/manager_interface.go b/pkg/mining/manager_interface.go new file mode 100644 index 0000000..ed3f1b2 --- /dev/null +++ b/pkg/mining/manager_interface.go @@ -0,0 +1,12 @@ +package mining + +// ManagerInterface defines the interface for a miner manager. +type ManagerInterface interface { + StartMiner(minerType string, config *Config) (Miner, error) + StopMiner(name string) error + GetMiner(name string) (Miner, error) + ListMiners() []Miner + ListAvailableMiners() []AvailableMiner + GetMinerHashrateHistory(name string) ([]HashratePoint, error) + Stop() +} diff --git a/pkg/mining/manager_test.go b/pkg/mining/manager_test.go new file mode 100644 index 0000000..716898c --- /dev/null +++ b/pkg/mining/manager_test.go @@ -0,0 +1,47 @@ +package mining + +import ( + "testing" +) + +// TestManager_StartStopMultipleMiners tests starting and stopping multiple miners. +func TestManager_StartStopMultipleMiners(t *testing.T) { + manager := NewManager() + defer manager.Stop() + + configs := []*Config{ + {Pool: "pool1", Wallet: "wallet1"}, + } + + minerNames := []string{"xmrig"} + + for i, config := range configs { + // Since we can't start a real miner in the test, we'll just check that the manager doesn't crash. + // A more complete test would involve a mock miner. + _, err := manager.StartMiner(minerNames[i], config) + if err == nil { + t.Errorf("Expected error when starting miner without executable") + } + } +} + +// TestManager_collectMinerStats tests the stat collection logic. +func TestManager_collectMinerStats(t *testing.T) { + manager := NewManager() + defer manager.Stop() + + // Since we can't start a real miner, we can't fully test this. + // A more complete test would involve a mock miner that can be added to the manager. + manager.collectMinerStats() +} + +// TestManager_GetMinerHashrateHistory tests getting hashrate history. +func TestManager_GetMinerHashrateHistory(t *testing.T) { + manager := NewManager() + defer manager.Stop() + + _, err := manager.GetMinerHashrateHistory("non-existent") + if err == nil { + t.Error("Expected error for getting hashrate history for non-existent miner") + } +} diff --git a/pkg/mining/mining.go b/pkg/mining/mining.go index 2abb95a..4aadc45 100644 --- a/pkg/mining/mining.go +++ b/pkg/mining/mining.go @@ -1,12 +1,9 @@ package mining import ( - "net/http" "os/exec" "sync" "time" - - "github.com/gin-gonic/gin" ) const ( @@ -56,15 +53,6 @@ type SystemInfo struct { InstalledMinersInfo []*InstallationDetails `json:"installed_miners_info"` } -type Service struct { - Manager *Manager - Router *gin.Engine - Server *http.Server - DisplayAddr string // The address to display in messages (e.g., 127.0.0.1:8080) - SwaggerInstanceName string - APIBasePath string // The base path for all API routes (e.g., /api/v1/mining) - SwaggerUIPath string // The path where Swagger UI assets are served (e.g., /api/v1/mining/swagger) -} // Config represents the config for a miner, including XMRig specific options type Config struct { diff --git a/pkg/mining/service.go b/pkg/mining/service.go index 6b18096..c7ec1c7 100644 --- a/pkg/mining/service.go +++ b/pkg/mining/service.go @@ -23,8 +23,19 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" ) +// Service encapsulates the gin-gonic router and the mining manager. +type Service struct { + Manager ManagerInterface + Router *gin.Engine + Server *http.Server + DisplayAddr string + SwaggerInstanceName string + APIBasePath string + SwaggerUIPath string +} + // NewService creates a new mining service -func NewService(manager *Manager, listenAddr string, displayAddr string, swaggerNamespace string) *Service { +func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) *Service { apiBasePath := "/" + strings.Trim(swaggerNamespace, "/") swaggerUIPath := apiBasePath + "/swagger" // Serve Swagger UI under a distinct sub-path diff --git a/pkg/mining/service_test.go b/pkg/mining/service_test.go new file mode 100644 index 0000000..34488cf --- /dev/null +++ b/pkg/mining/service_test.go @@ -0,0 +1,158 @@ +package mining + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// MockMiner is a mock implementation of the Miner interface for testing. +type MockMiner struct { + InstallFunc func() error + UninstallFunc func() error + StartFunc func(config *Config) error + StopFunc func() error + GetStatsFunc func() (*PerformanceMetrics, error) + GetNameFunc func() string + GetPathFunc func() string + GetBinaryPathFunc func() string + CheckInstallationFunc func() (*InstallationDetails, error) + GetLatestVersionFunc func() (string, error) + GetHashrateHistoryFunc func() []HashratePoint + AddHashratePointFunc func(point HashratePoint) + ReduceHashrateHistoryFunc func(now time.Time) +} + +func (m *MockMiner) Install() error { return m.InstallFunc() } +func (m *MockMiner) Uninstall() error { return m.UninstallFunc() } +func (m *MockMiner) Start(config *Config) error { return m.StartFunc(config) } +func (m *MockMiner) Stop() error { return m.StopFunc() } +func (m *MockMiner) GetStats() (*PerformanceMetrics, error) { return m.GetStatsFunc() } +func (m *MockMiner) GetName() string { return m.GetNameFunc() } +func (m *MockMiner) GetPath() string { return m.GetPathFunc() } +func (m *MockMiner) GetBinaryPath() string { return m.GetBinaryPathFunc() } +func (m *MockMiner) CheckInstallation() (*InstallationDetails, error) { return m.CheckInstallationFunc() } +func (m *MockMiner) GetLatestVersion() (string, error) { return m.GetLatestVersionFunc() } +func (m *MockMiner) GetHashrateHistory() []HashratePoint { return m.GetHashrateHistoryFunc() } +func (m *MockMiner) AddHashratePoint(point HashratePoint) { m.AddHashratePointFunc(point) } +func (m *MockMiner) ReduceHashrateHistory(now time.Time) { m.ReduceHashrateHistoryFunc(now) } + +// MockManager is a mock implementation of the Manager for testing. +type MockManager struct { + ListMinersFunc func() []Miner + ListAvailableMinersFunc func() []AvailableMiner + StartMinerFunc func(minerType string, config *Config) (Miner, error) + StopMinerFunc func(minerName string) error + GetMinerFunc func(minerName string) (Miner, error) + GetMinerHashrateHistoryFunc func(minerName string) ([]HashratePoint, error) + StopFunc func() +} + +func (m *MockManager) ListMiners() []Miner { return m.ListMinersFunc() } +func (m *MockManager) ListAvailableMiners() []AvailableMiner { return m.ListAvailableMinersFunc() } +func (m *MockManager) StartMiner(minerType string, config *Config) (Miner, error) { return m.StartMinerFunc(minerType, config) } +func (m *MockManager) StopMiner(minerName string) error { return m.StopMinerFunc(minerName) } +func (m *MockManager) GetMiner(minerName string) (Miner, error) { return m.GetMinerFunc(minerName) } +func (m *MockManager) GetMinerHashrateHistory(minerName string) ([]HashratePoint, error) { return m.GetMinerHashrateHistoryFunc(minerName) } +func (m *MockManager) Stop() { m.StopFunc() } + +var _ ManagerInterface = (*MockManager)(nil) + +func setupTestRouter() (*gin.Engine, *MockManager) { + gin.SetMode(gin.TestMode) + router := gin.Default() + mockManager := &MockManager{} + service := &Service{ + Manager: mockManager, + Router: router, + APIBasePath: "/", + SwaggerUIPath: "/swagger", + } + service.setupRoutes() + return router, mockManager +} + +func TestHandleListMiners(t *testing.T) { + router, mockManager := setupTestRouter() + mockManager.ListMinersFunc = func() []Miner { + return []Miner{&XMRigMiner{Name: "test-miner"}} + } + + req, _ := http.NewRequest("GET", "/miners", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestHandleStartMiner(t *testing.T) { + router, mockManager := setupTestRouter() + mockManager.StartMinerFunc = func(minerType string, config *Config) (Miner, error) { + return &XMRigMiner{Name: "test-miner"}, nil + } + + config := &Config{Pool: "pool", Wallet: "wallet"} + body, _ := json.Marshal(config) + req, _ := http.NewRequest("POST", "/miners/xmrig", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestHandleStopMiner(t *testing.T) { + router, mockManager := setupTestRouter() + mockManager.StopMinerFunc = func(minerName string) error { + return nil + } + + req, _ := http.NewRequest("DELETE", "/miners/test-miner", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestHandleGetMinerStats(t *testing.T) { + router, mockManager := setupTestRouter() + mockManager.GetMinerFunc = func(minerName string) (Miner, error) { + return &MockMiner{GetStatsFunc: func() (*PerformanceMetrics, error) { + return &PerformanceMetrics{Hashrate: 100}, nil + }}, nil + } + + req, _ := http.NewRequest("GET", "/miners/test-miner/stats", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestHandleGetMinerHashrateHistory(t *testing.T) { + router, mockManager := setupTestRouter() + mockManager.GetMinerHashrateHistoryFunc = func(minerName string) ([]HashratePoint, error) { + return []HashratePoint{{Timestamp: time.Now(), Hashrate: 100}}, nil + } + + req, _ := http.NewRequest("GET", "/miners/test-miner/hashrate-history", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/pkg/mining/xmrig_test.go b/pkg/mining/xmrig_test.go new file mode 100644 index 0000000..7bc36e7 --- /dev/null +++ b/pkg/mining/xmrig_test.go @@ -0,0 +1,222 @@ +package mining + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// MockRoundTripper is a mock implementation of http.RoundTripper for testing. +type MockRoundTripper func(req *http.Request) *http.Response + +// RoundTrip executes a single HTTP transaction, returning a Response for the given Request. +func (f MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +// newTestClient returns *http.Client with Transport replaced to avoid making real calls. +func newTestClient(fn MockRoundTripper) *http.Client { + return &http.Client{ + Transport: fn, + } +} + +// helper function to create a temporary directory for testing +func tempDir(t *testing.T) string { + dir, err := os.MkdirTemp("", "test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func TestNewXMRigMiner(t *testing.T) { + miner := NewXMRigMiner() + if miner == nil { + t.Fatal("NewXMRigMiner returned nil") + } + if miner.Name != "xmrig" { + t.Errorf("Expected miner name to be 'xmrig', got '%s'", miner.Name) + } + if miner.Version != "latest" { + t.Errorf("Expected miner version to be 'latest', got '%s'", miner.Version) + } + if !miner.API.Enabled { + t.Error("Expected API to be enabled by default") + } +} + +func TestXMRigMiner_GetName(t *testing.T) { + miner := NewXMRigMiner() + if name := miner.GetName(); name != "xmrig" { + t.Errorf("Expected GetName() to return 'xmrig', got '%s'", name) + } +} + +func TestXMRigMiner_GetLatestVersion(t *testing.T) { + originalClient := httpClient + httpClient = newTestClient(func(req *http.Request) *http.Response { + if req.URL.String() != "https://api.github.com/repos/xmrig/xmrig/releases/latest" { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("Not Found")), + Header: make(http.Header), + } + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"tag_name": "v6.18.0"}`)), + Header: make(http.Header), + } + }) + defer func() { httpClient = originalClient }() + + miner := NewXMRigMiner() + version, err := miner.GetLatestVersion() + if err != nil { + t.Fatalf("GetLatestVersion() returned an error: %v", err) + } + if version != "v6.18.0" { + t.Errorf("Expected version 'v6.18.0', got '%s'", version) + } +} + +func TestXMRigMiner_Start_Stop(t *testing.T) { + // Create a temporary directory for the dummy executable + tmpDir := t.TempDir() + dummyExePath := filepath.Join(tmpDir, "xmrig") + if runtime.GOOS == "windows" { + dummyExePath += ".exe" + } + + // Create a dummy executable file + if err := os.WriteFile(dummyExePath, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatalf("failed to create dummy executable: %v", err) + } + + miner := NewXMRigMiner() + miner.MinerBinary = dummyExePath + + config := &Config{ + Pool: "test:1234", + Wallet: "testwallet", + } + + err := miner.Start(config) + if err != nil { + t.Fatalf("Start() returned an error: %v", err) + } + if !miner.Running { + t.Fatal("Miner is not running after Start()") + } + + err = miner.Stop() + if err != nil { + // On some systems, stopping a process that quickly exits can error. We log but don't fail. + t.Logf("Stop() returned an error (often benign in tests): %v", err) + } + + // Give a moment for the process to be marked as not running + time.Sleep(100 * time.Millisecond) + + miner.mu.Lock() + if miner.Running { + miner.mu.Unlock() + t.Fatal("Miner is still running after Stop()") + } + miner.mu.Unlock() +} + +func TestXMRigMiner_GetStats(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + summary := XMRigSummary{ + Hashrate: struct { + Total []float64 `json:"total"` + }{Total: []float64{123.45}}, + Results: struct { + SharesGood uint64 `json:"shares_good"` + SharesTotal uint64 `json:"shares_total"` + }{SharesGood: 10, SharesTotal: 12}, + Uptime: 600, + Algorithm: "rx/0", + } + json.NewEncoder(w).Encode(summary) + })) + defer server.Close() + + originalHTTPClient := httpClient + httpClient = server.Client() + defer func() { httpClient = originalHTTPClient }() + + miner := NewXMRigMiner() + miner.Running = true // Mock running state + miner.API.ListenHost = strings.TrimPrefix(server.URL, "http://") + miner.API.ListenHost, miner.API.ListenPort = server.Listener.Addr().String(), 0 + parts := strings.Split(server.Listener.Addr().String(), ":") + miner.API.ListenHost = parts[0] + fmt.Sscanf(parts[1], "%d", &miner.API.ListenPort) + + stats, err := miner.GetStats() + if err != nil { + t.Fatalf("GetStats() returned an error: %v", err) + } + if stats.Hashrate != 123 { + t.Errorf("Expected hashrate 123, got %d", stats.Hashrate) + } + if stats.Shares != 10 { + t.Errorf("Expected 10 shares, got %d", stats.Shares) + } + if stats.Rejected != 2 { + t.Errorf("Expected 2 rejected shares, got %d", stats.Rejected) + } + if stats.Uptime != 600 { + t.Errorf("Expected uptime 600, got %d", stats.Uptime) + } + if stats.Algorithm != "rx/0" { + t.Errorf("Expected algorithm 'rx/0', got '%s'", stats.Algorithm) + } +} + +func TestXMRigMiner_HashrateHistory(t *testing.T) { + miner := NewXMRigMiner() + now := time.Now() + + // Add high-resolution points + for i := 0; i < 10; i++ { + miner.AddHashratePoint(HashratePoint{Timestamp: now.Add(time.Duration(i) * time.Second), Hashrate: 100 + i}) + } + + history := miner.GetHashrateHistory() + if len(history) != 10 { + t.Fatalf("Expected 10 hashrate points, got %d", len(history)) + } + + // Test ReduceHashrateHistory + // Move time forward to make some points eligible for reduction + future := now.Add(HighResolutionDuration + 30*time.Second) + miner.ReduceHashrateHistory(future) + + // After reduction, high-res history should be smaller + miner.mu.Lock() + if len(miner.HashrateHistory) >= 10 { + t.Errorf("High-res history not reduced, size: %d", len(miner.HashrateHistory)) + } + if len(miner.LowResHashrateHistory) == 0 { + t.Error("Low-res history not populated") + } + miner.mu.Unlock() + + combinedHistory := miner.GetHashrateHistory() + if len(combinedHistory) == 0 { + t.Error("GetHashrateHistory returned empty slice after reduction") + } +}