package deacon import ( "os" "path/filepath" "testing" "time" ) func TestHeartbeatFile(t *testing.T) { townRoot := "/tmp/test-town" expected := filepath.Join(townRoot, "deacon", "heartbeat.json") result := HeartbeatFile(townRoot) if result != expected { t.Errorf("HeartbeatFile() = %q, want %q", result, expected) } } func TestWriteReadHeartbeat(t *testing.T) { tmpDir, err := os.MkdirTemp("", "deacon-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() hb := &Heartbeat{ Timestamp: time.Now().UTC(), Cycle: 42, LastAction: "health check", HealthyAgents: 3, UnhealthyAgents: 1, } // Write heartbeat if err := WriteHeartbeat(tmpDir, hb); err != nil { t.Fatalf("WriteHeartbeat error: %v", err) } // Verify file exists hbFile := HeartbeatFile(tmpDir) if _, err := os.Stat(hbFile); err != nil { t.Errorf("heartbeat file not created: %v", err) } // Read heartbeat loaded := ReadHeartbeat(tmpDir) if loaded == nil { t.Fatal("ReadHeartbeat returned nil") } if loaded.Cycle != 42 { t.Errorf("Cycle = %d, want 42", loaded.Cycle) } if loaded.LastAction != "health check" { t.Errorf("LastAction = %q, want 'health check'", loaded.LastAction) } if loaded.HealthyAgents != 3 { t.Errorf("HealthyAgents = %d, want 3", loaded.HealthyAgents) } if loaded.UnhealthyAgents != 1 { t.Errorf("UnhealthyAgents = %d, want 1", loaded.UnhealthyAgents) } } func TestReadHeartbeat_NonExistent(t *testing.T) { tmpDir, err := os.MkdirTemp("", "deacon-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() // Read from non-existent file hb := ReadHeartbeat(tmpDir) if hb != nil { t.Error("expected nil for non-existent heartbeat") } } func TestHeartbeat_Age(t *testing.T) { // Test nil heartbeat var nilHb *Heartbeat if nilHb.Age() < 24*time.Hour { t.Error("nil heartbeat should have very large age") } // Test recent heartbeat hb := &Heartbeat{ Timestamp: time.Now().Add(-30 * time.Second), } if hb.Age() > time.Minute { t.Errorf("Age() = %v, expected < 1 minute", hb.Age()) } } func TestHeartbeat_IsFresh(t *testing.T) { tests := []struct { name string hb *Heartbeat expected bool }{ { name: "nil heartbeat", hb: nil, expected: false, }, { name: "just now", hb: &Heartbeat{ Timestamp: time.Now(), }, expected: true, }, { name: "3 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-3 * time.Minute), }, expected: true, // Fresh is <5 minutes }, { name: "6 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-6 * time.Minute), }, expected: false, // Not fresh (>=5 minutes) }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := tc.hb.IsFresh() if result != tc.expected { t.Errorf("IsFresh() = %v, want %v", result, tc.expected) } }) } } func TestHeartbeat_IsStale(t *testing.T) { tests := []struct { name string hb *Heartbeat expected bool }{ { name: "nil heartbeat", hb: nil, expected: false, }, { name: "3 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-3 * time.Minute), }, expected: false, // Fresh (<5 minutes) }, { name: "7 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-7 * time.Minute), }, expected: true, // Stale (5-15 minutes) }, { name: "16 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-16 * time.Minute), }, expected: false, // Very stale, not stale (>15 minutes) }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := tc.hb.IsStale() if result != tc.expected { t.Errorf("IsStale() = %v, want %v", result, tc.expected) } }) } } func TestHeartbeat_IsVeryStale(t *testing.T) { tests := []struct { name string hb *Heartbeat expected bool }{ { name: "nil heartbeat", hb: nil, expected: true, }, { name: "3 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-3 * time.Minute), }, expected: false, // Fresh }, { name: "10 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-10 * time.Minute), }, expected: false, // Stale but not very stale }, { name: "16 minutes old", hb: &Heartbeat{ Timestamp: time.Now().Add(-16 * time.Minute), }, expected: true, // Very stale (>15 minutes) }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := tc.hb.IsVeryStale() if result != tc.expected { t.Errorf("IsVeryStale() = %v, want %v", result, tc.expected) } }) } } func TestHeartbeat_ShouldPoke(t *testing.T) { tests := []struct { name string hb *Heartbeat expected bool }{ { name: "nil heartbeat - should poke", hb: nil, expected: true, }, { name: "fresh - no poke", hb: &Heartbeat{ Timestamp: time.Now(), }, expected: false, }, { name: "stale - no poke", hb: &Heartbeat{ Timestamp: time.Now().Add(-10 * time.Minute), }, expected: false, // Stale (5-15 min) but not very stale }, { name: "very stale - should poke", hb: &Heartbeat{ Timestamp: time.Now().Add(-16 * time.Minute), }, expected: true, // Very stale (>15 min) }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := tc.hb.ShouldPoke() if result != tc.expected { t.Errorf("ShouldPoke() = %v, want %v", result, tc.expected) } }) } } func TestTouch(t *testing.T) { tmpDir, err := os.MkdirTemp("", "deacon-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() // First touch if err := Touch(tmpDir); err != nil { t.Fatalf("Touch error: %v", err) } hb := ReadHeartbeat(tmpDir) if hb == nil { t.Fatal("expected heartbeat after Touch") } if hb.Cycle != 1 { t.Errorf("first Touch: Cycle = %d, want 1", hb.Cycle) } // Second touch should increment cycle if err := Touch(tmpDir); err != nil { t.Fatalf("Touch error: %v", err) } hb = ReadHeartbeat(tmpDir) if hb.Cycle != 2 { t.Errorf("second Touch: Cycle = %d, want 2", hb.Cycle) } } func TestTouchWithAction(t *testing.T) { tmpDir, err := os.MkdirTemp("", "deacon-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() if err := TouchWithAction(tmpDir, "health scan", 5, 2); err != nil { t.Fatalf("TouchWithAction error: %v", err) } hb := ReadHeartbeat(tmpDir) if hb == nil { t.Fatal("expected heartbeat after TouchWithAction") } if hb.Cycle != 1 { t.Errorf("Cycle = %d, want 1", hb.Cycle) } if hb.LastAction != "health scan" { t.Errorf("LastAction = %q, want 'health scan'", hb.LastAction) } if hb.HealthyAgents != 5 { t.Errorf("HealthyAgents = %d, want 5", hb.HealthyAgents) } if hb.UnhealthyAgents != 2 { t.Errorf("UnhealthyAgents = %d, want 2", hb.UnhealthyAgents) } } func TestWriteHeartbeat_CreatesDirectory(t *testing.T) { tmpDir, err := os.MkdirTemp("", "deacon-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() // Ensure deacon directory doesn't exist deaconDir := filepath.Join(tmpDir, "deacon") if _, err := os.Stat(deaconDir); !os.IsNotExist(err) { t.Fatal("deacon directory should not exist initially") } // Write heartbeat should create directory hb := &Heartbeat{Cycle: 1} if err := WriteHeartbeat(tmpDir, hb); err != nil { t.Fatalf("WriteHeartbeat error: %v", err) } // Verify directory was created if _, err := os.Stat(deaconDir); err != nil { t.Errorf("deacon directory should exist: %v", err) } } func TestWriteHeartbeat_SetsTimestamp(t *testing.T) { tmpDir, err := os.MkdirTemp("", "deacon-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() // Write heartbeat without timestamp hb := &Heartbeat{Cycle: 1} if err := WriteHeartbeat(tmpDir, hb); err != nil { t.Fatalf("WriteHeartbeat error: %v", err) } // Read back and verify timestamp was set loaded := ReadHeartbeat(tmpDir) if loaded == nil { t.Fatal("expected heartbeat") } if loaded.Timestamp.IsZero() { t.Error("expected Timestamp to be set") } if time.Since(loaded.Timestamp) > time.Minute { t.Error("Timestamp should be recent") } }