From cec0a8c6d4cd0d4f0050b1c1f9b01462486ad418 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 19:18:50 -0500 Subject: [PATCH] test(18-02): add failing tests for ProfileCache and compileProfileData - 11 test cases covering JSONB compilation, prefix matching, fallback - Tests reference compileProfileData, ProfileCache, sysOIDEntry (not yet implemented) - types.go created with CompiledProfile, PollGroup, ScalarOID, TableOID, ColumnOID Co-Authored-By: Claude Opus 4.6 (1M context) --- poller/internal/snmp/profiles_test.go | 336 ++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 poller/internal/snmp/profiles_test.go diff --git a/poller/internal/snmp/profiles_test.go b/poller/internal/snmp/profiles_test.go new file mode 100644 index 0000000..a9d3c7c --- /dev/null +++ b/poller/internal/snmp/profiles_test.go @@ -0,0 +1,336 @@ +package snmp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// genericSNMPProfileJSON is the full generic-snmp profile_data from the design spec. +// It contains system, interfaces, health, and custom poll groups. +const genericSNMPProfileJSON = `{ + "version": 1, + "poll_groups": { + "system": { + "interval_multiplier": 1, + "scalars": [ + {"oid": "1.3.6.1.2.1.1.1.0", "name": "sys_descr", "type": "string", "map_to": "device.model"}, + {"oid": "1.3.6.1.2.1.1.3.0", "name": "sys_uptime", "type": "timeticks", "map_to": "device.uptime_seconds"}, + {"oid": "1.3.6.1.2.1.1.5.0", "name": "sys_name", "type": "string", "map_to": "device.hostname_discovered"} + ] + }, + "interfaces": { + "interval_multiplier": 1, + "tables": [ + { + "oid": "1.3.6.1.2.1.2.2", + "name": "ifTable", + "index_oid": "1.3.6.1.2.1.2.2.1.1", + "columns": [ + {"oid": "1.3.6.1.2.1.2.2.1.2", "name": "ifDescr", "type": "string"}, + {"oid": "1.3.6.1.2.1.2.2.1.5", "name": "ifSpeed", "type": "gauge"}, + {"oid": "1.3.6.1.2.1.2.2.1.7", "name": "ifAdminStatus", "type": "integer"}, + {"oid": "1.3.6.1.2.1.2.2.1.8", "name": "ifOperStatus", "type": "integer"}, + {"oid": "1.3.6.1.2.1.2.2.1.10", "name": "ifInOctets", "type": "counter32"}, + {"oid": "1.3.6.1.2.1.2.2.1.16", "name": "ifOutOctets", "type": "counter32"} + ], + "map_to": "interface_metrics" + }, + { + "oid": "1.3.6.1.2.1.31.1.1", + "name": "ifXTable", + "index_oid": "1.3.6.1.2.1.31.1.1.1.1", + "columns": [ + {"oid": "1.3.6.1.2.1.31.1.1.1.1", "name": "ifName", "type": "string"}, + {"oid": "1.3.6.1.2.1.31.1.1.1.6", "name": "ifHCInOctets", "type": "counter64"}, + {"oid": "1.3.6.1.2.1.31.1.1.1.10", "name": "ifHCOutOctets", "type": "counter64"}, + {"oid": "1.3.6.1.2.1.31.1.1.1.15", "name": "ifHighSpeed", "type": "gauge"} + ], + "map_to": "interface_metrics", + "prefer_over": "ifTable" + } + ] + }, + "health": { + "interval_multiplier": 1, + "scalars": [ + { + "oid": "1.3.6.1.2.1.25.3.3.1.2", + "name": "hrProcessorLoad", + "type": "integer", + "map_to": "health_metrics.cpu_load" + }, + { + "oid": "1.3.6.1.4.1.2021.11.11.0", + "name": "ssCpuIdle", + "type": "integer", + "transform": "invert_percent", + "map_to": "health_metrics.cpu_load", + "fallback_for": "hrProcessorLoad" + } + ], + "tables": [ + { + "oid": "1.3.6.1.2.1.25.2.3", + "name": "hrStorageTable", + "index_oid": "1.3.6.1.2.1.25.2.3.1.1", + "columns": [ + {"oid": "1.3.6.1.2.1.25.2.3.1.2", "name": "hrStorageType", "type": "oid"}, + {"oid": "1.3.6.1.2.1.25.2.3.1.3", "name": "hrStorageDescr", "type": "string"}, + {"oid": "1.3.6.1.2.1.25.2.3.1.4", "name": "hrStorageAllocationUnits", "type": "integer"}, + {"oid": "1.3.6.1.2.1.25.2.3.1.5", "name": "hrStorageSize", "type": "integer"}, + {"oid": "1.3.6.1.2.1.25.2.3.1.6", "name": "hrStorageUsed", "type": "integer"} + ], + "map_to": "health_metrics", + "filter": {"hrStorageType": ["1.3.6.1.2.1.25.2.1.2", "1.3.6.1.2.1.25.2.1.4"]} + } + ] + }, + "custom": { + "interval_multiplier": 5, + "scalars": [], + "tables": [] + } + } +}` + +func TestCompileProfileData_FullGenericProfile(t *testing.T) { + profile, err := compileProfileData([]byte(genericSNMPProfileJSON)) + require.NoError(t, err) + require.NotNil(t, profile) + + // Should have 4 poll groups: system, interfaces, health, custom + assert.Len(t, profile.PollGroups, 4) + assert.Contains(t, profile.PollGroups, "system") + assert.Contains(t, profile.PollGroups, "interfaces") + assert.Contains(t, profile.PollGroups, "health") + assert.Contains(t, profile.PollGroups, "custom") + + // System group: 3 scalars, 0 tables + sys := profile.PollGroups["system"] + assert.Equal(t, 1, sys.IntervalMultiplier) + assert.Len(t, sys.Scalars, 3) + assert.Empty(t, sys.Tables) + + // Interfaces group: 0 scalars, 2 tables + ifaces := profile.PollGroups["interfaces"] + assert.Equal(t, 1, ifaces.IntervalMultiplier) + assert.Empty(t, ifaces.Scalars) + assert.Len(t, ifaces.Tables, 2) + + // Health group: 2 scalars, 1 table + health := profile.PollGroups["health"] + assert.Equal(t, 1, health.IntervalMultiplier) + assert.Len(t, health.Scalars, 2) + assert.Len(t, health.Tables, 1) + + // Custom group: empty with multiplier 5 + custom := profile.PollGroups["custom"] + assert.Equal(t, 5, custom.IntervalMultiplier) + assert.Empty(t, custom.Scalars) + assert.Empty(t, custom.Tables) +} + +func TestCompileProfileData_ScalarFields(t *testing.T) { + profile, err := compileProfileData([]byte(genericSNMPProfileJSON)) + require.NoError(t, err) + + sys := profile.PollGroups["system"] + require.Len(t, sys.Scalars, 3) + + // First scalar: sys_descr + s := sys.Scalars[0] + assert.Equal(t, "1.3.6.1.2.1.1.1.0", s.OID) + assert.Equal(t, "sys_descr", s.Name) + assert.Equal(t, "string", s.Type) + assert.Equal(t, "device.model", s.MapTo) + assert.Empty(t, s.Transform) + assert.Empty(t, s.FallbackFor) + + // Health scalar with transform and fallback_for + health := profile.PollGroups["health"] + require.Len(t, health.Scalars, 2) + fb := health.Scalars[1] + assert.Equal(t, "ssCpuIdle", fb.Name) + assert.Equal(t, "invert_percent", fb.Transform) + assert.Equal(t, "hrProcessorLoad", fb.FallbackFor) +} + +func TestCompileProfileData_TableFields(t *testing.T) { + profile, err := compileProfileData([]byte(genericSNMPProfileJSON)) + require.NoError(t, err) + + ifaces := profile.PollGroups["interfaces"] + require.Len(t, ifaces.Tables, 2) + + // ifTable + ifTable := ifaces.Tables[0] + assert.Equal(t, "1.3.6.1.2.1.2.2", ifTable.OID) + assert.Equal(t, "ifTable", ifTable.Name) + assert.Equal(t, "1.3.6.1.2.1.2.2.1.1", ifTable.IndexOID) + assert.Len(t, ifTable.Columns, 6) + assert.Equal(t, "interface_metrics", ifTable.MapTo) + assert.Empty(t, ifTable.PreferOver) + + // ifXTable with prefer_over + ifXTable := ifaces.Tables[1] + assert.Equal(t, "ifXTable", ifXTable.Name) + assert.Equal(t, "ifTable", ifXTable.PreferOver) + assert.Len(t, ifXTable.Columns, 4) + + // Verify column fields + col := ifTable.Columns[0] + assert.Equal(t, "1.3.6.1.2.1.2.2.1.2", col.OID) + assert.Equal(t, "ifDescr", col.Name) + assert.Equal(t, "string", col.Type) + + // Health table with filter + health := profile.PollGroups["health"] + require.Len(t, health.Tables, 1) + storage := health.Tables[0] + assert.Equal(t, "hrStorageTable", storage.Name) + assert.Len(t, storage.Columns, 5) + require.Contains(t, storage.Filter, "hrStorageType") + assert.Len(t, storage.Filter["hrStorageType"], 2) +} + +func TestCompileProfileData_InvalidJSON(t *testing.T) { + _, err := compileProfileData([]byte(`{invalid json`)) + assert.Error(t, err) +} + +func TestCompileProfileData_EmptyJSON(t *testing.T) { + _, err := compileProfileData([]byte(`{}`)) + require.NoError(t, err) +} + +func TestProfileCache_GetUnknownID(t *testing.T) { + cache := &ProfileCache{ + profiles: make(map[string]*CompiledProfile), + } + result := cache.Get("nonexistent-uuid") + assert.Nil(t, result, "Get with unknown ID should return nil, not panic") +} + +func TestProfileCache_GetKnownID(t *testing.T) { + p := &CompiledProfile{ID: "abc-123", Name: "test-profile"} + cache := &ProfileCache{ + profiles: map[string]*CompiledProfile{"abc-123": p}, + } + result := cache.Get("abc-123") + require.NotNil(t, result) + assert.Equal(t, "test-profile", result.Name) +} + +func TestMatchSysObjectID_PrefixMatch(t *testing.T) { + cache := &ProfileCache{ + profiles: map[string]*CompiledProfile{ + "mikrotik-uuid": {ID: "mikrotik-uuid", Name: "mikrotik-snmp"}, + "generic-uuid": {ID: "generic-uuid", Name: "generic-snmp"}, + }, + sysOIDMap: []sysOIDEntry{ + {Prefix: "1.3.6.1.4.1.14988", ProfileID: "mikrotik-uuid"}, + }, + genericID: "generic-uuid", + } + + // Mikrotik sysObjectID should match mikrotik prefix + result := cache.MatchSysObjectID("1.3.6.1.4.1.14988.1.2") + assert.Equal(t, "mikrotik-uuid", result) +} + +func TestMatchSysObjectID_FallbackToGeneric(t *testing.T) { + cache := &ProfileCache{ + profiles: map[string]*CompiledProfile{ + "mikrotik-uuid": {ID: "mikrotik-uuid", Name: "mikrotik-snmp"}, + "generic-uuid": {ID: "generic-uuid", Name: "generic-snmp"}, + }, + sysOIDMap: []sysOIDEntry{ + {Prefix: "1.3.6.1.4.1.14988", ProfileID: "mikrotik-uuid"}, + }, + genericID: "generic-uuid", + } + + // Unknown vendor OID should fall back to generic-snmp + result := cache.MatchSysObjectID("1.3.6.1.4.1.99999.1.2") + assert.Equal(t, "generic-uuid", result) +} + +func TestMatchSysObjectID_LongestPrefixWins(t *testing.T) { + cache := &ProfileCache{ + profiles: map[string]*CompiledProfile{ + "mikrotik-broad-uuid": {ID: "mikrotik-broad-uuid", Name: "mikrotik-broad"}, + "mikrotik-narrow-uuid": {ID: "mikrotik-narrow-uuid", Name: "mikrotik-narrow"}, + "generic-uuid": {ID: "generic-uuid", Name: "generic-snmp"}, + }, + sysOIDMap: []sysOIDEntry{ + // Sorted by prefix length descending (longest first) + {Prefix: "1.3.6.1.4.1.14988.1", ProfileID: "mikrotik-narrow-uuid"}, + {Prefix: "1.3.6.1.4.1.14988", ProfileID: "mikrotik-broad-uuid"}, + }, + genericID: "generic-uuid", + } + + // "1.3.6.1.4.1.14988.1.2.3" matches both prefixes -- longest should win + result := cache.MatchSysObjectID("1.3.6.1.4.1.14988.1.2.3") + assert.Equal(t, "mikrotik-narrow-uuid", result) + + // "1.3.6.1.4.1.14988.2.1" matches only the shorter prefix + result = cache.MatchSysObjectID("1.3.6.1.4.1.14988.2.1") + assert.Equal(t, "mikrotik-broad-uuid", result) +} + +func TestMatchSysObjectID_ExactMatch(t *testing.T) { + cache := &ProfileCache{ + profiles: map[string]*CompiledProfile{ + "exact-uuid": {ID: "exact-uuid", Name: "exact-match"}, + "generic-uuid": {ID: "generic-uuid", Name: "generic-snmp"}, + }, + sysOIDMap: []sysOIDEntry{ + {Prefix: "1.3.6.1.4.1.12345", ProfileID: "exact-uuid"}, + }, + genericID: "generic-uuid", + } + + // Exact match (sysObjectID equals prefix exactly) + result := cache.MatchSysObjectID("1.3.6.1.4.1.12345") + assert.Equal(t, "exact-uuid", result) +} + +func TestMatchSysObjectID_EmptyCache(t *testing.T) { + cache := &ProfileCache{ + profiles: make(map[string]*CompiledProfile), + sysOIDMap: nil, + genericID: "generic-uuid", + } + + // With empty sysOIDMap, should return genericID + result := cache.MatchSysObjectID("1.3.6.1.4.1.14988.1") + assert.Equal(t, "generic-uuid", result) +} + +// TestCompileProfileData_VerifyRoundTrip ensures compiled profile JSON matches +// what we'd expect from re-serialization (verifies no data is lost). +func TestCompileProfileData_VerifyRoundTrip(t *testing.T) { + profile, err := compileProfileData([]byte(genericSNMPProfileJSON)) + require.NoError(t, err) + + // Count total OIDs across all groups + totalScalars := 0 + totalTables := 0 + for _, pg := range profile.PollGroups { + totalScalars += len(pg.Scalars) + totalTables += len(pg.Tables) + } + assert.Equal(t, 5, totalScalars, "should have 5 total scalars (3 system + 2 health)") + assert.Equal(t, 3, totalTables, "should have 3 total tables (2 interfaces + 1 health)") + + // Verify the JSON struct parses correctly by checking intermediate format + var raw profileDataJSON + err = json.Unmarshal([]byte(genericSNMPProfileJSON), &raw) + require.NoError(t, err) + assert.Equal(t, 1, raw.Version) + assert.Len(t, raw.PollGroups, 4) +}