package agentsource import ( "context" "encoding/json" "errors" "testing" "time" "github.com/itworx/pulse/internal/agentstore" "github.com/itworx/pulse/internal/array" "github.com/itworx/pulse/internal/container" "github.com/itworx/pulse/internal/disk" "github.com/itworx/pulse/internal/host" "github.com/itworx/pulse/internal/pool" "github.com/itworx/pulse/internal/process" "github.com/itworx/pulse/internal/share" ) // stubReader stands in for the PostgreSQL store so the read path can be exercised without // a database. type stubReader struct { snapshot agentstore.Snapshot err error asked agentstore.Capability } func (r *stubReader) Latest(_ context.Context, capability agentstore.Capability) (agentstore.Snapshot, error) { r.asked = capability if r.err != nil { return agentstore.Snapshot{}, r.err } return r.snapshot, nil } // observation is the part of every domain snapshot this package is responsible for. type observation struct { state string reason string healthy bool } // domainUnderTest describes one capability generically so every domain runs the same // table: no snapshot, store failure, stale snapshot, corrupt payload, payload the domain // rejects, and a good payload. type domainUnderTest struct { name string capability agentstore.Capability window time.Duration good func(now time.Time) any rejected func(now time.Time) any observe func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) } func floatPtr(value float64) *float64 { return &value } func domains() []domainUnderTest { return []domainUnderTest{ { name: "host", capability: agentstore.CapabilityHost, window: DefaultHostWindow, good: func(now time.Time) any { used := uint64(6 * 1024 * 1024 * 1024) return host.RawSnapshot{ Source: host.Source{ID: "agent-1", Type: "agent"}, Identity: host.HostIdentity{Name: "pulse-host", Version: "7.2.2", Arch: "amd64"}, UptimeSeconds: 3600, CPU: host.RawCPU{TotalPercent: floatPtr(42.5), PerCore: []float64{40, 45}, IOWaitPercent: floatPtr(2)}, Load: host.RawLoad{One: 0.2, Five: 0.1, Fifteen: 0.1}, Memory: host.RawMemory{TotalBytes: 8 * 1024 * 1024 * 1024, AvailableBytes: 2 * 1024 * 1024 * 1024, UsedBytes: &used}, Filesystems: []host.RawFilesystem{{Mount: "/", Filesystem: "xfs", CapacityBytes: 100, UsedBytes: 25}}, Network: []host.RawNetworkInterface{{Name: "eth0", State: "up"}}, Time: host.RawTime{Synchronized: true, OffsetSeconds: 0.002, Stratum: 2}, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return host.RawSnapshot{Identity: host.HostIdentity{Name: ""}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := HostProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{ state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.Freshness == host.Fresh && snapshot.Status.State == host.StatusHealthy, }, err }, }, { name: "processes", capability: agentstore.CapabilityProcesses, window: DefaultProcessesWindow, good: func(now time.Time) any { return process.RawSnapshot{ Source: process.Source{ID: "agent-1", Type: "agent"}, Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping", RuntimeSeconds: 100, CPUPercent: 1, MemoryBytes: 500}}, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return process.RawSnapshot{Processes: []process.RawProcess{{PID: 0, Name: ""}}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := ProcessProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.State == "healthy"}, err }, }, { name: "containers", capability: agentstore.CapabilityContainers, window: DefaultContainersWindow, good: func(now time.Time) any { return container.RawSnapshot{ Source: container.Source{ID: "agent-1", Type: "agent"}, Containers: []container.RawContainer{{ID: "a", Name: "alpha", State: "running", Health: "healthy"}}, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return container.RawSnapshot{Containers: []container.RawContainer{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := ContainerProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{ state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.Freshness == "fresh" && snapshot.Source.State == "healthy", }, err }, }, { name: "array", capability: agentstore.CapabilityArray, window: DefaultArrayWindow, good: func(now time.Time) any { return array.RawSnapshot{ Source: array.Source{ID: "agent-1", Type: "unraid"}, State: array.StateOperational, Parity: array.RawParity{Present: true, State: "idle"}, Members: []array.RawMember{ {ID: "disk1", Name: "Disk 1", Role: "data", State: "online", CapacityBytes: 100}, {ID: "parity", Name: "Parity", Role: "parity", State: "online", CapacityBytes: 100}, }, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return array.RawSnapshot{State: array.StateOperational, Members: []array.RawMember{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := ArrayProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{ state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.Freshness == array.Fresh && snapshot.State == array.StateOperational, }, err }, }, { name: "disks", capability: agentstore.CapabilityDisks, window: DefaultDisksWindow, good: func(now time.Time) any { return disk.RawSnapshot{ Source: disk.Source{ID: "agent-1", Type: "unraid"}, Disks: []disk.RawDisk{{ID: "disk1", Name: "Disk 1", Role: "data", State: disk.StateOnline, SizeBytes: 100}}, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return disk.RawSnapshot{Disks: []disk.RawDisk{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := DiskProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{ state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.Freshness == disk.Fresh && snapshot.Source.State != disk.StateUnknown, }, err }, }, { name: "pools", capability: agentstore.CapabilityPools, window: DefaultPoolsWindow, good: func(now time.Time) any { return pool.RawSnapshot{ Source: pool.Source{ID: "agent-1", Type: "unraid"}, Pools: []pool.RawPool{{ID: "cache", Name: "Cache", Filesystem: "btrfs", State: pool.StateHealthy, UsableBytes: 1000, UsedBytes: 100}}, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return pool.RawSnapshot{Pools: []pool.RawPool{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := PoolProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{ state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.Freshness == pool.Fresh && snapshot.Source.State != pool.StateUnknown, }, err }, }, { name: "shares", capability: agentstore.CapabilityShares, window: DefaultSharesWindow, good: func(now time.Time) any { return share.RawSnapshot{ Source: share.Source{ID: "agent-1", Type: "unraid"}, Shares: []share.RawShare{{ID: "share-media", Name: "Media", UsedBytes: 700, SizeObservedAt: now, SizeState: share.SizeCached}}, ObservedAt: now, ReceivedAt: now, } }, rejected: func(now time.Time) any { return share.RawSnapshot{Shares: []share.RawShare{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now} }, observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) { snapshot, err := ShareProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx) return observation{ state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.Freshness == share.Fresh && snapshot.Source.State != share.StateUnknown, }, err }, }, } } func encode(t *testing.T, value any) json.RawMessage { t.Helper() payload, err := json.Marshal(value) if err != nil { t.Fatal(err) } return payload } func TestProvidersMapUnusableTelemetryToUnknown(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) for _, domain := range domains() { t.Run(domain.name, func(t *testing.T) { fresh := agentstore.Snapshot{AgentID: "agent-1", Capability: domain.capability, ObservedAt: now, ReceivedAt: now} cases := []struct { name string reader *stubReader reason string }{ {"no snapshot", &stubReader{err: agentstore.ErrNoSnapshot}, ReasonUnavailable}, {"store unreachable", &stubReader{err: errors.New("connection refused")}, ReasonUnavailable}, {"stale snapshot", &stubReader{snapshot: func() agentstore.Snapshot { stale := fresh stale.ObservedAt = now.Add(-domain.window - time.Second) stale.Payload = encode(t, domain.good(stale.ObservedAt)) return stale }()}, ReasonStale}, {"missing observation time", &stubReader{snapshot: func() agentstore.Snapshot { broken := fresh broken.ObservedAt = time.Time{} broken.Payload = encode(t, domain.good(now)) return broken }()}, ReasonStale}, {"corrupt payload", &stubReader{snapshot: func() agentstore.Snapshot { corrupt := fresh corrupt.Payload = json.RawMessage(`{"source":"not-an-object"}`) return corrupt }()}, ReasonInvalid}, {"payload the domain rejects", &stubReader{snapshot: func() agentstore.Snapshot { invalid := fresh invalid.Payload = encode(t, domain.rejected(now)) return invalid }()}, ReasonInvalid}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { got, err := domain.observe(context.Background(), testCase.reader, now) if err != nil { t.Fatalf("unusable telemetry must not fail the request: %v", err) } if got.healthy { t.Fatal("unusable telemetry became healthy, violating ADR-0008") } if got.state != "unknown" { t.Fatalf("source state = %q, want unknown", got.state) } if got.reason != testCase.reason { t.Fatalf("reason = %q, want %q", got.reason, testCase.reason) } if testCase.reader.asked != domain.capability { t.Fatalf("read capability %q, want %q", testCase.reader.asked, domain.capability) } }) } }) } } func TestProvidersNormalizeGoodTelemetry(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) for _, domain := range domains() { t.Run(domain.name, func(t *testing.T) { observed := now.Add(-time.Second) reader := &stubReader{snapshot: agentstore.Snapshot{ AgentID: "agent-1", Capability: domain.capability, ObservedAt: observed, ReceivedAt: observed, Payload: encode(t, domain.good(observed)), }} got, err := domain.observe(context.Background(), reader, now) if err != nil { t.Fatal(err) } if !got.healthy { t.Fatalf("fresh telemetry did not normalize to a healthy source: %+v", got) } if got.reason != "" { t.Fatalf("healthy source carries reason %q", got.reason) } }) } } func TestProvidersTreatTheWindowBoundaryAsFresh(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) for _, domain := range domains() { t.Run(domain.name, func(t *testing.T) { observed := now.Add(-domain.window) reader := &stubReader{snapshot: agentstore.Snapshot{ AgentID: "agent-1", Capability: domain.capability, ObservedAt: observed, ReceivedAt: observed, Payload: encode(t, domain.good(observed)), }} got, err := domain.observe(context.Background(), reader, now) if err != nil { t.Fatal(err) } if got.state == "unknown" { t.Fatalf("a snapshot exactly at the window edge must still be usable: %+v", got) } }) } } func TestProvidersWithoutAReaderReportUnavailable(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) for _, domain := range domains() { t.Run(domain.name, func(t *testing.T) { got, err := domain.observe(context.Background(), nil, now) if err != nil { t.Fatal(err) } if got.healthy || got.state != "unknown" || got.reason != ReasonUnavailable { t.Fatalf("unexpected observation %+v", got) } }) } } func TestProvidersPropagateContextCancellation(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) ctx, cancel := context.WithCancel(context.Background()) cancel() for _, domain := range domains() { t.Run(domain.name, func(t *testing.T) { reader := &stubReader{err: context.Canceled} if _, err := domain.observe(ctx, reader, now); !errors.Is(err, context.Canceled) { t.Fatalf("expected cancellation, got %v", err) } }) } } func TestWindowsApplyDocumentedDefaults(t *testing.T) { windows := Windows{} expected := map[agentstore.Capability]time.Duration{ agentstore.CapabilityHost: DefaultHostWindow, agentstore.CapabilityProcesses: DefaultProcessesWindow, agentstore.CapabilityContainers: DefaultContainersWindow, agentstore.CapabilityArray: DefaultArrayWindow, agentstore.CapabilityDisks: DefaultDisksWindow, agentstore.CapabilityPools: DefaultPoolsWindow, agentstore.CapabilityShares: DefaultSharesWindow, } for _, capability := range agentstore.Capabilities() { if got := windows.For(capability); got != expected[capability] { t.Fatalf("window for %q = %s, want %s", capability, got, expected[capability]) } } if windows.For(agentstore.CapabilityHost) >= windows.For(agentstore.CapabilityShares) { t.Fatal("the host is sampled far more often than shares and must have the tighter window") } if err := windows.Validate(); err != nil { t.Fatalf("defaults must validate: %v", err) } if err := (Windows{Host: 48 * time.Hour}).Validate(); err == nil { t.Fatal("a window beyond a day must be rejected") } if err := (Windows{Shares: -time.Second}).Validate(); err == nil { t.Fatal("a negative window must be rejected") } custom := Windows{Host: 5 * time.Second}.WithDefaults() if custom.Host != 5*time.Second || custom.Shares != DefaultSharesWindow { t.Fatalf("configuration was not preserved: %+v", custom) } } func TestConfiguredWindowOverridesTheDefault(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) observed := now.Add(-10 * time.Second) payload := encode(t, process.RawSnapshot{ Source: process.Source{ID: "agent-1", Type: "agent"}, Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping"}}, ObservedAt: observed, ReceivedAt: observed, }) reader := &stubReader{snapshot: agentstore.Snapshot{AgentID: "agent-1", Capability: agentstore.CapabilityProcesses, ObservedAt: observed, ReceivedAt: observed, Payload: payload}} provider := ProcessProvider{Reader: reader, Windows: Windows{Processes: 5 * time.Second}, Now: fixedClock(now)} snapshot, err := provider.Snapshot(context.Background()) if err != nil { t.Fatal(err) } if snapshot.Source.State != "unknown" || snapshot.Source.Reason != ReasonStale { t.Fatalf("a tightened window must make the snapshot stale: %+v", snapshot.Source) } }