mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
445 lines
12 KiB
Go
445 lines
12 KiB
Go
package licensing
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testClock is a controllable clock for deterministic tests.
|
|
type testClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
}
|
|
|
|
func newTestClock(t time.Time) *testClock {
|
|
return &testClock{now: t}
|
|
}
|
|
|
|
func (c *testClock) Now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.now
|
|
}
|
|
|
|
func (c *testClock) Advance(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.now = c.now.Add(d)
|
|
}
|
|
|
|
func newTestTracker(clock *testClock) *HostLifecycleTracker {
|
|
t := NewHostLifecycleTracker()
|
|
t.nowFunc = clock.Now
|
|
return t
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// H2: Stabilization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestNewHostIsNotStable(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
|
|
if tracker.IsStable("host-1") {
|
|
t.Fatal("new host should not be stable immediately")
|
|
}
|
|
// Should not appear in stable-active list.
|
|
if got := tracker.StableActiveHosts(); len(got) != 0 {
|
|
t.Fatalf("expected 0 stable-active hosts, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestHostBecomesStableAfter10Min(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
|
|
// 9 minutes: still not stable.
|
|
clock.Advance(9 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1")
|
|
if tracker.IsStable("host-1") {
|
|
t.Fatal("host should not be stable before 10 minutes")
|
|
}
|
|
|
|
// Exactly 10 minutes: becomes stable on heartbeat.
|
|
clock.Advance(1 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1")
|
|
if !tracker.IsStable("host-1") {
|
|
t.Fatal("host should be stable after 10 minutes of heartbeats")
|
|
}
|
|
|
|
hosts := tracker.StableActiveHosts()
|
|
if len(hosts) != 1 || hosts[0] != "host-1" {
|
|
t.Fatalf("expected [host-1], got %v", hosts)
|
|
}
|
|
}
|
|
|
|
func TestStabilityIsNotLostOnInactivity(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
clock.Advance(15 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1")
|
|
if !tracker.IsStable("host-1") {
|
|
t.Fatal("host should be stable")
|
|
}
|
|
|
|
// After 50h (still within 72h), host is still stable.
|
|
clock.Advance(50 * time.Hour)
|
|
if !tracker.IsStable("host-1") {
|
|
t.Fatal("stability should not be cleared by passage of time alone")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// H3: Inactivity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHostBecomesInactiveAfter72h(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
clock.Advance(15 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1") // now stable
|
|
|
|
if !tracker.IsActive("host-1") {
|
|
t.Fatal("host should be active right after heartbeat")
|
|
}
|
|
|
|
// Just within the window.
|
|
clock.Advance(72 * time.Hour)
|
|
if !tracker.IsActive("host-1") {
|
|
t.Fatal("host should still be active at exactly 72h")
|
|
}
|
|
|
|
// Past the window.
|
|
clock.Advance(1 * time.Second)
|
|
if tracker.IsActive("host-1") {
|
|
t.Fatal("host should be inactive after 72h + 1s")
|
|
}
|
|
|
|
// Stable but inactive — should NOT be in stable-active list.
|
|
if got := tracker.StableActiveHosts(); len(got) != 0 {
|
|
t.Fatalf("expected 0 stable-active hosts after inactivity, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatResetsInactivityTimer(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
clock.Advance(15 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1")
|
|
|
|
// Advance close to 72h, then send another heartbeat.
|
|
clock.Advance(71 * time.Hour)
|
|
tracker.RecordHeartbeat("host-1")
|
|
|
|
// Another 71h — total 142h since first seen, but only 71h since last heartbeat.
|
|
clock.Advance(71 * time.Hour)
|
|
if !tracker.IsActive("host-1") {
|
|
t.Fatal("host should still be active — heartbeat reset the timer")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prune
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPruneRemovesInactiveHosts(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("keep")
|
|
tracker.RecordHeartbeat("remove")
|
|
|
|
// Make "remove" inactive.
|
|
clock.Advance(73 * time.Hour)
|
|
tracker.RecordHeartbeat("keep") // keep stays alive
|
|
|
|
tracker.Prune()
|
|
|
|
if tracker.HostCount() != 1 {
|
|
t.Fatalf("expected 1 host after prune, got %d", tracker.HostCount())
|
|
}
|
|
if tracker.IsActive("remove") {
|
|
t.Fatal("pruned host should not exist")
|
|
}
|
|
if !tracker.IsActive("keep") {
|
|
t.Fatal("kept host should still be active")
|
|
}
|
|
}
|
|
|
|
func TestPruneNoOpWhenAllActive(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("a")
|
|
tracker.RecordHeartbeat("b")
|
|
tracker.Prune()
|
|
|
|
if tracker.HostCount() != 2 {
|
|
t.Fatalf("expected 2 hosts, got %d", tracker.HostCount())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FirstSeen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFirstSeen(t *testing.T) {
|
|
start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
clock := newTestClock(start)
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
clock.Advance(5 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1") // should not change firstSeen
|
|
|
|
got := tracker.FirstSeen("host-1")
|
|
if !got.Equal(start) {
|
|
t.Fatalf("FirstSeen should be %v, got %v", start, got)
|
|
}
|
|
|
|
// Unknown host returns zero.
|
|
if fs := tracker.FirstSeen("unknown"); !fs.IsZero() {
|
|
t.Fatalf("FirstSeen for unknown host should be zero, got %v", fs)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unknown host defaults
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestUnknownHostDefaults(t *testing.T) {
|
|
tracker := NewHostLifecycleTracker()
|
|
|
|
if tracker.IsStable("nope") {
|
|
t.Fatal("unknown host should not be stable")
|
|
}
|
|
if tracker.IsActive("nope") {
|
|
t.Fatal("unknown host should not be active")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multiple hosts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestMultipleHostsIndependent(t *testing.T) {
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("a")
|
|
clock.Advance(5 * time.Minute)
|
|
tracker.RecordHeartbeat("b")
|
|
clock.Advance(5 * time.Minute)
|
|
// a: 10min elapsed, b: 5min elapsed
|
|
tracker.RecordHeartbeat("a")
|
|
tracker.RecordHeartbeat("b")
|
|
|
|
if !tracker.IsStable("a") {
|
|
t.Fatal("a should be stable (10min)")
|
|
}
|
|
if tracker.IsStable("b") {
|
|
t.Fatal("b should not be stable yet (5min)")
|
|
}
|
|
|
|
clock.Advance(5 * time.Minute)
|
|
tracker.RecordHeartbeat("b")
|
|
if !tracker.IsStable("b") {
|
|
t.Fatal("b should be stable now (10min)")
|
|
}
|
|
|
|
hosts := tracker.StableActiveHosts()
|
|
if len(hosts) != 2 {
|
|
t.Fatalf("expected 2 stable-active hosts, got %d", len(hosts))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistence
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSaveAndLoadState(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "lifecycle.json")
|
|
|
|
clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
tracker := newTestTracker(clock)
|
|
|
|
tracker.RecordHeartbeat("host-1")
|
|
clock.Advance(15 * time.Minute)
|
|
tracker.RecordHeartbeat("host-1") // stable
|
|
|
|
tracker.RecordHeartbeat("host-2") // not stable yet
|
|
|
|
if err := tracker.SaveState(path); err != nil {
|
|
t.Fatalf("SaveState: %v", err)
|
|
}
|
|
|
|
// Load into a fresh tracker.
|
|
tracker2 := newTestTracker(clock)
|
|
if err := tracker2.LoadState(path); err != nil {
|
|
t.Fatalf("LoadState: %v", err)
|
|
}
|
|
|
|
if !tracker2.IsStable("host-1") {
|
|
t.Fatal("host-1 should be stable after load")
|
|
}
|
|
if tracker2.IsStable("host-2") {
|
|
t.Fatal("host-2 should not be stable after load")
|
|
}
|
|
if tracker2.HostCount() != 2 {
|
|
t.Fatalf("expected 2 hosts after load, got %d", tracker2.HostCount())
|
|
}
|
|
|
|
// FirstSeen preserved.
|
|
expected := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
if fs := tracker2.FirstSeen("host-1"); !fs.Equal(expected) {
|
|
t.Fatalf("FirstSeen not preserved: want %v, got %v", expected, fs)
|
|
}
|
|
}
|
|
|
|
func TestLoadStateMissingFile(t *testing.T) {
|
|
tracker := NewHostLifecycleTracker()
|
|
if err := tracker.LoadState("/nonexistent/path/lifecycle.json"); err != nil {
|
|
t.Fatalf("LoadState on missing file should not error, got: %v", err)
|
|
}
|
|
if tracker.HostCount() != 0 {
|
|
t.Fatal("tracker should be empty after loading missing file")
|
|
}
|
|
}
|
|
|
|
func TestLoadStateEmptyFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "lifecycle.json")
|
|
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Pre-populate to verify empty file replaces state.
|
|
tracker := NewHostLifecycleTracker()
|
|
tracker.RecordHeartbeat("old")
|
|
|
|
if err := tracker.LoadState(path); err != nil {
|
|
t.Fatalf("LoadState on empty file should not error, got: %v", err)
|
|
}
|
|
if tracker.HostCount() != 0 {
|
|
t.Fatalf("expected 0 hosts after loading empty file, got %d", tracker.HostCount())
|
|
}
|
|
}
|
|
|
|
func TestLoadStateNullHostEntry(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "lifecycle.json")
|
|
// Simulate a corrupted file with a null entry.
|
|
if err := os.WriteFile(path, []byte(`{"hosts":{"h1":null,"h2":{"first_seen":"2026-01-01T00:00:00Z","last_seen":"2026-01-01T00:15:00Z","stable_at":"2026-01-01T00:10:00Z"}}}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tracker := NewHostLifecycleTracker()
|
|
if err := tracker.LoadState(path); err != nil {
|
|
t.Fatalf("LoadState should handle null entries, got: %v", err)
|
|
}
|
|
// Null entry filtered out; valid entry kept.
|
|
if tracker.HostCount() != 1 {
|
|
t.Fatalf("expected 1 host (null filtered), got %d", tracker.HostCount())
|
|
}
|
|
if !tracker.IsStable("h2") {
|
|
t.Fatal("h2 should be stable")
|
|
}
|
|
}
|
|
|
|
func TestLoadStateNullHostsMap(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "lifecycle.json")
|
|
if err := os.WriteFile(path, []byte(`{"hosts":null}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Pre-populate tracker to verify LoadState replaces state.
|
|
tracker := NewHostLifecycleTracker()
|
|
tracker.RecordHeartbeat("old-host")
|
|
|
|
if err := tracker.LoadState(path); err != nil {
|
|
t.Fatalf("LoadState: %v", err)
|
|
}
|
|
if tracker.HostCount() != 0 {
|
|
t.Fatalf("expected 0 hosts after loading null map, got %d", tracker.HostCount())
|
|
}
|
|
}
|
|
|
|
func TestLoadStateReplacesExistingState(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "lifecycle.json")
|
|
if err := os.WriteFile(path, []byte(`{}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tracker := NewHostLifecycleTracker()
|
|
tracker.RecordHeartbeat("stale")
|
|
|
|
if err := tracker.LoadState(path); err != nil {
|
|
t.Fatalf("LoadState: %v", err)
|
|
}
|
|
// Loading an empty object should replace existing state.
|
|
if tracker.HostCount() != 0 {
|
|
t.Fatalf("expected 0 hosts after loading empty state, got %d", tracker.HostCount())
|
|
}
|
|
}
|
|
|
|
func TestEmptyHostIDIgnored(t *testing.T) {
|
|
tracker := NewHostLifecycleTracker()
|
|
tracker.RecordHeartbeat("")
|
|
if tracker.HostCount() != 0 {
|
|
t.Fatalf("empty hostID should be ignored, got %d hosts", tracker.HostCount())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Thread safety
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConcurrentAccess(t *testing.T) {
|
|
tracker := NewHostLifecycleTracker()
|
|
|
|
var wg sync.WaitGroup
|
|
const goroutines = 50
|
|
const iterations = 100
|
|
|
|
wg.Add(goroutines)
|
|
for i := 0; i < goroutines; i++ {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
hostID := "host-" + string(rune('A'+id%26))
|
|
for j := 0; j < iterations; j++ {
|
|
tracker.RecordHeartbeat(hostID)
|
|
tracker.IsStable(hostID)
|
|
tracker.IsActive(hostID)
|
|
tracker.FirstSeen(hostID)
|
|
tracker.StableActiveHosts()
|
|
if j%50 == 0 {
|
|
tracker.Prune()
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// No panics or races = pass. Just verify basic sanity.
|
|
if tracker.HostCount() == 0 {
|
|
t.Fatal("expected some hosts after concurrent access")
|
|
}
|
|
}
|