Merge pull request #4 from Snider/feature-increase-test-coverage

feat: Increase test coverage for pkg/mining
This commit is contained in:
Snider 2025-11-10 00:59:13 +00:00 committed by GitHub
commit 01fb04ef02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 467 additions and 13 deletions

View file

@ -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{

View file

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

View file

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

View file

@ -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 {

View file

@ -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

158
pkg/mining/service_test.go Normal file
View file

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

View file

@ -574,6 +574,18 @@ func (m *XMRigMiner) AddHashratePoint(point HashratePoint) {
// ReduceHashrateHistory aggregates older high-resolution data into 1-minute averages
// and adds them to the low-resolution history.
func (m *XMRigMiner) GetHighResHistoryLength() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.HashrateHistory)
}
func (m *XMRigMiner) GetLowResHistoryLength() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.LowResHashrateHistory)
}
func (m *XMRigMiner) ReduceHashrateHistory(now time.Time) {
m.mu.Lock()
defer m.mu.Unlock()

224
pkg/mining/xmrig_test.go Normal file
View file

@ -0,0 +1,224 @@
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 += ".bat"
// Create a dummy batch file for Windows
if err := os.WriteFile(dummyExePath, []byte("@echo off\n"), 0755); err != nil {
t.Fatalf("failed to create dummy executable: %v", err)
}
} else {
// Create a dummy shell script for other OSes
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
if miner.GetHighResHistoryLength() >= 10 {
t.Errorf("High-res history not reduced, size: %d", miner.GetHighResHistoryLength())
}
if miner.GetLowResHistoryLength() == 0 {
t.Error("Low-res history not populated")
}
combinedHistory := miner.GetHashrateHistory()
if len(combinedHistory) == 0 {
t.Error("GetHashrateHistory returned empty slice after reduction")
}
}