package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "github.com/go-chi/chi/v5" ) func readyTraceSessionWithHop(agentID string) *TraceSession { sess := &TraceSession{ ID: "sess-fork-test-12345678", AgentIDs: []string{agentID}, Ready: true, CreatedAt: time.Now(), Hops: []*HopInfo{{ AgentID: agentID, AgentName: "Target Hop", ExternalIP: "203.0.113.5", Port: 51820, Status: HopReady, }}, } initCanonicalTimeline(sess) return sess } func TestBuildPathTracerMermaidIncludesGhostBranches(t *testing.T) { sess := readyTraceSessionWithHop("agent-target") sess.TimelineBranches = append(sess.TimelineBranches, &TimelineBranch{ ID: "ghost-abc-12345678", ForkHopIndex: 0, Persona: "aggressive", Status: BranchRunning, IsGhost: true, }) out := buildPathTracerMermaid(sess, timelineBranchSnapshot(sess)) if !strings.Contains(out, "graph TD") { t.Fatal("expected mermaid graph") } if !strings.Contains(out, "aggressive") { t.Fatalf("expected persona in mermaid: %s", out) } if !strings.Contains(out, "class ghost_") { t.Fatalf("expected ghost node: %s", out) } } func TestPathTracerForkSpawnsGhostBranches(t *testing.T) { database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) agentID := "fork-agent-001" _ = database.UpsertAgent(&models.Agent{ID: agentID, Name: "Hop One", Status: "online"}) hub := NewWSHub(database) startPathTracerAgentResponder(t, hub, agentID, "FORK_PUB_KEY") h := NewPathTracerHandler(hub) sess := readyTraceSessionWithHop(agentID) h.mu.Lock() h.sessions[sess.ID] = sess h.mu.Unlock() body, _ := json.Marshal(map[string]interface{}{ "session_id": sess.ID, "fork_hop_index": 0, "personas": []string{"aggressive", "silent"}, }) req := httptest.NewRequest(http.MethodPost, "/pathtrace/fork", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Fork(rec, req) if rec.Code != http.StatusOK { t.Fatalf("fork status %d: %s", rec.Code, rec.Body.String()) } var resp struct { OK bool `json:"ok"` Branches []TimelineBranch `json:"branches"` Mermaid string `json:"mermaid"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp.Branches) != 2 { t.Fatalf("want 2 branches, got %d", len(resp.Branches)) } for _, b := range resp.Branches { if !b.IsGhost || b.Status != BranchRunning { t.Fatalf("unexpected branch: %+v", b) } } if !strings.Contains(resp.Mermaid, "aggressive") { t.Fatalf("mermaid missing persona: %s", resp.Mermaid) } restored := h.getSession(sess.ID) if len(restored.TimelineBranches) < 3 { t.Fatalf("expected canonical + 2 ghosts, got %d", len(restored.TimelineBranches)) } } func TestPathTracerMergeWinner(t *testing.T) { database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) agentID := "merge-agent-001" hub := NewWSHub(database) h := NewPathTracerHandler(hub) sess := readyTraceSessionWithHop(agentID) winner := &TimelineBranch{ ID: "ghost-winner-branch", ParentID: sess.ID, ForkHopIndex: 0, TargetAgentID: agentID, Persona: "persuasive", SpreadLanes: []string{"dns_txt", "docker"}, Status: BranchWon, MiningLinked: true, Hashrate: 420.5, IsGhost: true, CreatedAt: time.Now(), } sess.TimelineBranches = append(sess.TimelineBranches, winner) h.mu.Lock() h.sessions[sess.ID] = sess h.mu.Unlock() body, _ := json.Marshal(map[string]interface{}{ "session_id": sess.ID, "branch_id": winner.ID, }) req := httptest.NewRequest(http.MethodPost, "/pathtrace/merge", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Merge(rec, req) if rec.Code != http.StatusOK { t.Fatalf("merge status %d: %s", rec.Code, rec.Body.String()) } var resp struct { MergedPersona string `json:"merged_persona"` MergedSpreadLane string `json:"merged_spread_lane"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.MergedPersona != "persuasive" || resp.MergedSpreadLane != "dns_txt" { t.Fatalf("merge response: %+v", resp) } restored := h.getSession(sess.ID) if restored.MergedPersona != "persuasive" || restored.MergedBranchID != winner.ID { t.Fatalf("session merge fields not set: %+v", restored) } if findTimelineBranch(restored, winner.ID).Status != BranchMerged { t.Fatal("winner should be merged") } } func TestPathTracerForkRejectsUnreadySession(t *testing.T) { hub := NewWSHub(nil) h := NewPathTracerHandler(hub) sess := readyTraceSessionWithHop("agent-x") sess.Ready = false h.mu.Lock() h.sessions[sess.ID] = sess h.mu.Unlock() body, _ := json.Marshal(map[string]interface{}{ "session_id": sess.ID, "fork_hop_index": 0, }) req := httptest.NewRequest(http.MethodPost, "/pathtrace/fork", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Fork(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("want 409, got %d", rec.Code) } } func TestPathTracerTimelinePersistRoundTrip(t *testing.T) { database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) hub := NewWSHub(database) h1 := NewPathTracerHandler(hub) sess := readyTraceSessionWithHop("persist-agent") sess.TimelineBranches = append(sess.TimelineBranches, &TimelineBranch{ ID: "ghost-persist", Persona: "balanced", Status: BranchRunning, IsGhost: true, }) sess.MergedPersona = "balanced" h1.mu.Lock() h1.sessions[sess.ID] = sess h1.mu.Unlock() h1.persistSession(sess) h2 := NewPathTracerHandler(hub) restored := h2.getSession(sess.ID) if restored == nil || restored.MergedPersona != "balanced" { t.Fatalf("timeline not restored: %+v", restored) } if len(restored.TimelineBranches) < 2 { t.Fatal("branches not restored from SQLite") } } func TestPathTracerStatusIncludesTimeline(t *testing.T) { hub := NewWSHub(nil) h := NewPathTracerHandler(hub) sess := readyTraceSessionWithHop("status-agent") h.mu.Lock() h.sessions[sess.ID] = sess h.mu.Unlock() r := chi.NewRouter() r.Get("/pathtrace/{id}/status", h.Status) req := httptest.NewRequest(http.MethodGet, "/pathtrace/"+sess.ID+"/status", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } if !strings.Contains(rec.Body.String(), "timeline_branches") { t.Fatalf("missing timeline in status: %s", rec.Body.String()) } if !strings.Contains(rec.Body.String(), "mermaid") { t.Fatalf("missing mermaid in status: %s", rec.Body.String()) } } func TestAgentMiningHashratePrefersLiveTelemetry(t *testing.T) { database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) agentID := "hr-agent" _ = database.UpsertAgent(&models.Agent{ID: agentID, Name: "Miner", Status: "online"}) hub := NewWSHub(database) hub.cacheAgentTelemetry(agentID, map[string]interface{}{ "mining_hashrate": 777.0, "hashrate_15s": 100.0, }) h := NewPathTracerHandler(hub) if got := h.agentMiningHashrate(agentID); got != 777 { t.Fatalf("want live mining_hashrate 777, got %v", got) } } func TestAgentMiningHashrateFallsBackToDB(t *testing.T) { database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) agentID := "hr-agent-db" _ = database.UpsertAgent(&models.Agent{ID: agentID, Name: "Miner", Status: "online"}) _ = database.UpdateAgentStats(agentID, 555, 550, 540, 0, 0, 0, 0, 0, 0) h := NewPathTracerHandler(NewWSHub(database)) if got := h.agentMiningHashrate(agentID); got != 555 { t.Fatalf("want hashrate_15s 555, got %v", got) } }