- Implement computeCounterDelta for Counter32/Counter64 with wraparound handling
- Sanity threshold discards deltas > 90% of max value (device reset detection)
- CounterCache uses Redis MGET/MSET pipelining for efficient state persistence
- Counter keys use "snmp:counter:{device_id}:{oid}" format with 600s TTL
- Add SNMPMetricsEvent and SNMPMetricEntry structs to bus package
- Add PublishSNMPMetrics publishing to "device.metrics.snmp_custom.{device_id}"
- Full test coverage: 10 counter tests including miniredis integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
4.4 KiB
Go
141 lines
4.4 KiB
Go
package snmp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- computeCounterDelta unit tests ---
|
|
|
|
func TestCounterDelta_NormalIncrement32(t *testing.T) {
|
|
delta, ok := computeCounterDelta(100, 200, 32)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, uint64(100), delta)
|
|
}
|
|
|
|
func TestCounterDelta_Counter32Wraparound(t *testing.T) {
|
|
// 4294967290 -> 10 wraps around MaxUint32 (4294967295)
|
|
// delta = (4294967295 - 4294967290) + 10 + 1 = 16
|
|
delta, ok := computeCounterDelta(4294967290, 10, 32)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, uint64(16), delta)
|
|
}
|
|
|
|
func TestCounterDelta_Counter32Reset(t *testing.T) {
|
|
// 100 -> 50: curr < prev, wrap delta would be huge (> 90% of MaxUint32)
|
|
// This indicates a device reset, not a legitimate wrap.
|
|
_, ok := computeCounterDelta(100, 50, 32)
|
|
assert.False(t, ok, "should discard: delta > 90%% of MaxUint32 indicates device reset")
|
|
}
|
|
|
|
func TestCounterDelta_Counter64NormalIncrement(t *testing.T) {
|
|
delta, ok := computeCounterDelta(1000000, 2000000, 64)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, uint64(1000000), delta)
|
|
}
|
|
|
|
func TestCounterDelta_Counter64Reset(t *testing.T) {
|
|
// prev=1000000000000 -> curr=50: wrap delta = (MaxUint64 - 1e12) + 50 + 1
|
|
// which is > 90% of MaxUint64, indicating a device reset (not a wrap).
|
|
_, ok := computeCounterDelta(1000000000000, 50, 64)
|
|
assert.False(t, ok, "should discard: moderate prev to tiny curr on 64-bit indicates device reset")
|
|
}
|
|
|
|
func TestCounterDelta_ZeroDelta(t *testing.T) {
|
|
delta, ok := computeCounterDelta(500, 500, 32)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, uint64(0), delta)
|
|
}
|
|
|
|
// --- CounterCache integration tests ---
|
|
|
|
func TestCounterCache_FirstPollReturnsEmpty(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
defer rdb.Close()
|
|
|
|
cc := NewCounterCache(rdb)
|
|
ctx := context.Background()
|
|
|
|
counters := map[string]CounterInput{
|
|
".1.3.6.1.2.1.2.2.1.10.1": {Value: 1000, Bits: 32},
|
|
".1.3.6.1.2.1.2.2.1.16.1": {Value: 500, Bits: 32},
|
|
}
|
|
|
|
results, err := cc.ComputeDeltas(ctx, "dev-001", counters)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, results, "first poll should produce no results (no previous values)")
|
|
}
|
|
|
|
func TestCounterCache_SecondPollComputesRate(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
defer rdb.Close()
|
|
|
|
cc := NewCounterCache(rdb)
|
|
ctx := context.Background()
|
|
|
|
// First poll: seed the cache
|
|
counters1 := map[string]CounterInput{
|
|
".1.3.6.1.2.1.2.2.1.10.1": {Value: 1000, Bits: 32},
|
|
}
|
|
_, err := cc.ComputeDeltas(ctx, "dev-001", counters1)
|
|
require.NoError(t, err)
|
|
|
|
// Advance time in miniredis by 10 seconds
|
|
mr.FastForward(10 * time.Second)
|
|
|
|
// Manually adjust the stored timestamp to be 10 seconds ago.
|
|
// We do this because computeDeltas uses time.Now() internally,
|
|
// and miniredis FastForward doesn't affect Go's time.Now().
|
|
key := counterKey("dev-001", ".1.3.6.1.2.1.2.2.1.10.1")
|
|
nowUnix := time.Now().Unix()
|
|
rdb.Set(ctx, key, fmt.Sprintf(`{"value":1000,"ts":%d}`, nowUnix-10), 600*time.Second)
|
|
|
|
// Second poll: should compute delta and rate
|
|
counters2 := map[string]CounterInput{
|
|
".1.3.6.1.2.1.2.2.1.10.1": {Value: 2000, Bits: 32},
|
|
}
|
|
results, err := cc.ComputeDeltas(ctx, "dev-001", counters2)
|
|
require.NoError(t, err)
|
|
require.Len(t, results, 1)
|
|
|
|
assert.Equal(t, ".1.3.6.1.2.1.2.2.1.10.1", results[0].OID)
|
|
assert.Equal(t, uint64(1000), results[0].Delta)
|
|
assert.InDelta(t, 100.0, results[0].Rate, 1.0, "rate should be ~100 (1000 delta / 10s)")
|
|
assert.InDelta(t, 10.0, results[0].ElapsedSeconds, 1.0)
|
|
}
|
|
|
|
func TestCounterCache_RedisKeyFormat(t *testing.T) {
|
|
key := counterKey("device-abc", ".1.3.6.1.2.1.2.2.1.10.1")
|
|
assert.Equal(t, "snmp:counter:device-abc:.1.3.6.1.2.1.2.2.1.10.1", key)
|
|
}
|
|
|
|
func TestCounterCache_StoresWithTTL(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
defer rdb.Close()
|
|
|
|
cc := NewCounterCache(rdb)
|
|
ctx := context.Background()
|
|
|
|
counters := map[string]CounterInput{
|
|
".1.3.6.1.2.1.2.2.1.10.1": {Value: 1000, Bits: 32},
|
|
}
|
|
|
|
_, err := cc.ComputeDeltas(ctx, "dev-001", counters)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the key exists in Redis with a TTL
|
|
key := counterKey("dev-001", ".1.3.6.1.2.1.2.2.1.10.1")
|
|
ttl := mr.TTL(key)
|
|
assert.True(t, ttl > 0 && ttl <= 600*time.Second, "TTL should be set to ~600s, got %v", ttl)
|
|
}
|