diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..ce76a844 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,271 @@ +package config + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testConfig struct { + Host string `validate:"required"` + Port int `validate:"required"` + Optional string +} + +type nestedConfig struct { + Database struct { + Host string `validate:"required"` + Port int `validate:"required"` + } `validate:"required"` + Server struct { + Name string `validate:"required"` + } `validate:"required"` +} + +func TestParseConfig(t *testing.T) { + t.Run("parse valid YAML config", func(t *testing.T) { + yaml := ` +host: localhost +port: 8080 +optional: value +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Equal(t, "localhost", cfg.Host) + assert.Equal(t, 8080, cfg.Port) + assert.Equal(t, "value", cfg.Optional) + }) + + t.Run("parse minimal valid config", func(t *testing.T) { + yaml := ` +host: localhost +port: 8080 +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Equal(t, "localhost", cfg.Host) + assert.Equal(t, 8080, cfg.Port) + assert.Empty(t, cfg.Optional) + }) + + t.Run("parse null config", func(t *testing.T) { + yaml := "null\n" + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Empty(t, cfg.Host) + assert.Equal(t, 0, cfg.Port) + }) + + t.Run("error on missing required field", func(t *testing.T) { + yaml := ` +host: localhost +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing or incorrect configuration fields") + assert.Contains(t, err.Error(), "port") + }) + + t.Run("error on missing multiple required fields", func(t *testing.T) { + yaml := ` +optional: value +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing or incorrect configuration fields") + assert.Contains(t, err.Error(), "host") + assert.Contains(t, err.Error(), "port") + }) + + t.Run("error on invalid YAML", func(t *testing.T) { + yaml := ` +host: localhost +port: invalid + bad indentation +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "unmarshalling config yaml") + }) + + t.Run("error on malformed YAML", func(t *testing.T) { + yaml := `{{{invalid yaml` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "unmarshalling config yaml") + }) + + t.Run("parse nested config", func(t *testing.T) { + yaml := ` +database: + host: db.example.com + port: 5432 +server: + name: web-server +` + var cfg nestedConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Equal(t, "db.example.com", cfg.Database.Host) + assert.Equal(t, 5432, cfg.Database.Port) + assert.Equal(t, "web-server", cfg.Server.Name) + }) + + t.Run("error on missing nested required field", func(t *testing.T) { + yaml := ` +database: + host: db.example.com +server: + name: web-server +` + var cfg nestedConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing or incorrect configuration fields") + }) + + t.Run("parse config with numbers", func(t *testing.T) { + yaml := ` +host: 192.168.1.1 +port: 9090 +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Equal(t, "192.168.1.1", cfg.Host) + assert.Equal(t, 9090, cfg.Port) + }) + + t.Run("parse config with boolean and special characters", func(t *testing.T) { + type specialConfig struct { + Host string `validate:"required"` + Enabled bool + Path string + } + yaml := ` +host: "host-name.with-dashes" +enabled: true +path: "/var/lib/data" +` + var cfg specialConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Equal(t, "host-name.with-dashes", cfg.Host) + assert.True(t, cfg.Enabled) + assert.Equal(t, "/var/lib/data", cfg.Path) + }) + + t.Run("parse empty config", func(t *testing.T) { + yaml := `` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing or incorrect configuration fields") + }) + + t.Run("parse config with extra fields", func(t *testing.T) { + yaml := ` +host: localhost +port: 8080 +extra_field: ignored +another_field: also_ignored +` + var cfg testConfig + err := ParseConfig(bytes.NewReader([]byte(yaml)), &cfg) + require.NoError(t, err) + assert.Equal(t, "localhost", cfg.Host) + assert.Equal(t, 8080, cfg.Port) + }) + + t.Run("error reading from bad reader", func(t *testing.T) { + badReader := &errorReader{} + var cfg testConfig + err := ParseConfig(badReader, &cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "while reading configuration") + }) +} + +func TestSetCamelCase(t *testing.T) { + t.Run("convert simple field name", func(t *testing.T) { + input := "Config.Host" + result := setCamelCase(input) + assert.Equal(t, "host", result) + }) + + t.Run("convert nested field name", func(t *testing.T) { + input := "Config.Database.Host" + result := setCamelCase(input) + assert.Equal(t, "database.host", result) + }) + + t.Run("convert deeply nested field name", func(t *testing.T) { + input := "Config.Server.Database.Connection.Host" + result := setCamelCase(input) + assert.Equal(t, "server.database.connection.host", result) + }) + + t.Run("convert field with already lowercase first letter", func(t *testing.T) { + input := "config.host" + result := setCamelCase(input) + assert.Equal(t, "host", result) + }) + + t.Run("convert single segment", func(t *testing.T) { + input := "Config" + result := setCamelCase(input) + assert.Equal(t, "", result) + }) + + t.Run("convert field with uppercase words", func(t *testing.T) { + input := "Config.HTTPServer.Port" + result := setCamelCase(input) + assert.Equal(t, "hTTPServer.port", result) + }) + + t.Run("convert multiple segments", func(t *testing.T) { + input := "TestConfig.Host.Name" + result := setCamelCase(input) + assert.Equal(t, "host.name", result) + }) +} + +func TestValidate(t *testing.T) { + t.Run("validator is initialized", func(t *testing.T) { + require.NotNil(t, Validate) + }) + + t.Run("can validate struct", func(t *testing.T) { + cfg := testConfig{ + Host: "localhost", + Port: 8080, + } + err := Validate.Struct(cfg) + assert.NoError(t, err) + }) + + t.Run("validation fails on missing required field", func(t *testing.T) { + cfg := testConfig{ + Host: "localhost", + } + err := Validate.Struct(cfg) + assert.Error(t, err) + }) +} + +// errorReader is a helper type that always returns an error when reading +type errorReader struct{} + +func (e *errorReader) Read(p []byte) (n int, err error) { + return 0, strings.NewReader("").UnreadByte() +} diff --git a/pkg/data/data_test.go b/pkg/data/data_test.go new file mode 100644 index 00000000..34d1611f --- /dev/null +++ b/pkg/data/data_test.go @@ -0,0 +1,241 @@ +package data + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMetricType_String(t *testing.T) { + t.Run("UNTYPED metric type", func(t *testing.T) { + mt := UNTYPED + assert.Equal(t, "untyped", mt.String()) + }) + + t.Run("COUNTER metric type", func(t *testing.T) { + mt := COUNTER + assert.Equal(t, "counter", mt.String()) + }) + + t.Run("GAUGE metric type", func(t *testing.T) { + mt := GAUGE + assert.Equal(t, "gauge", mt.String()) + }) + + t.Run("metric type with value 0", func(t *testing.T) { + mt := MetricType(0) + assert.Equal(t, "untyped", mt.String()) + }) + + t.Run("metric type with value 1", func(t *testing.T) { + mt := MetricType(1) + assert.Equal(t, "counter", mt.String()) + }) + + t.Run("metric type with value 2", func(t *testing.T) { + mt := MetricType(2) + assert.Equal(t, "gauge", mt.String()) + }) +} + +func TestEventType_String(t *testing.T) { + t.Run("ERROR event type", func(t *testing.T) { + et := ERROR + assert.Equal(t, "error", et.String()) + }) + + t.Run("EVENT event type", func(t *testing.T) { + et := EVENT + assert.Equal(t, "event", et.String()) + }) + + t.Run("LOG event type", func(t *testing.T) { + et := LOG + assert.Equal(t, "log", et.String()) + }) + + t.Run("RESULT event type", func(t *testing.T) { + et := RESULT + assert.Equal(t, "result", et.String()) + }) + + t.Run("TASK event type", func(t *testing.T) { + et := TASK + assert.Equal(t, "task", et.String()) + }) + + t.Run("event type with value 0", func(t *testing.T) { + et := EventType(0) + assert.Equal(t, "error", et.String()) + }) + + t.Run("event type with value 1", func(t *testing.T) { + et := EventType(1) + assert.Equal(t, "event", et.String()) + }) + + t.Run("event type with value 2", func(t *testing.T) { + et := EventType(2) + assert.Equal(t, "log", et.String()) + }) + + t.Run("event type with value 3", func(t *testing.T) { + et := EventType(3) + assert.Equal(t, "result", et.String()) + }) + + t.Run("event type with value 4", func(t *testing.T) { + et := EventType(4) + assert.Equal(t, "task", et.String()) + }) +} + +func TestEventSeverity_String(t *testing.T) { + t.Run("UNKNOWN severity", func(t *testing.T) { + es := UNKNOWN + assert.Equal(t, "unknown", es.String()) + }) + + t.Run("DEBUG severity", func(t *testing.T) { + es := DEBUG + assert.Equal(t, "debug", es.String()) + }) + + t.Run("INFO severity", func(t *testing.T) { + es := INFO + assert.Equal(t, "info", es.String()) + }) + + t.Run("WARNING severity", func(t *testing.T) { + es := WARNING + assert.Equal(t, "warning", es.String()) + }) + + t.Run("CRITICAL severity", func(t *testing.T) { + es := CRITICAL + assert.Equal(t, "critical", es.String()) + }) + + t.Run("severity with value 0", func(t *testing.T) { + es := EventSeverity(0) + assert.Equal(t, "unknown", es.String()) + }) + + t.Run("severity with value 1", func(t *testing.T) { + es := EventSeverity(1) + assert.Equal(t, "debug", es.String()) + }) + + t.Run("severity with value 2", func(t *testing.T) { + es := EventSeverity(2) + assert.Equal(t, "info", es.String()) + }) + + t.Run("severity with value 3", func(t *testing.T) { + es := EventSeverity(3) + assert.Equal(t, "warning", es.String()) + }) + + t.Run("severity with value 4", func(t *testing.T) { + es := EventSeverity(4) + assert.Equal(t, "critical", es.String()) + }) +} + +func TestEvent(t *testing.T) { + t.Run("create event with all fields", func(t *testing.T) { + event := Event{ + Index: "test-index", + Time: float64(time.Now().Unix()), + Type: EVENT, + Publisher: "test-publisher", + Severity: INFO, + Labels: map[string]interface{}{ + "host": "localhost", + "pod": "test-pod", + }, + Annotations: map[string]interface{}{ + "description": "test event", + }, + Message: "test message", + } + + assert.Equal(t, "test-index", event.Index) + assert.Equal(t, EVENT, event.Type) + assert.Equal(t, "test-publisher", event.Publisher) + assert.Equal(t, INFO, event.Severity) + assert.Equal(t, "test message", event.Message) + assert.NotNil(t, event.Labels) + assert.NotNil(t, event.Annotations) + }) + + t.Run("create event with minimal fields", func(t *testing.T) { + event := Event{ + Type: ERROR, + Message: "error message", + } + + assert.Equal(t, ERROR, event.Type) + assert.Equal(t, "error message", event.Message) + }) +} + +func TestMetric(t *testing.T) { + t.Run("create metric with all fields", func(t *testing.T) { + metric := Metric{ + Name: "cpu_usage", + Time: float64(time.Now().Unix()), + Type: GAUGE, + Interval: time.Second * 10, + Value: 75.5, + LabelKeys: []string{"host", "cpu"}, + LabelVals: []string{"localhost", "cpu0"}, + } + + assert.Equal(t, "cpu_usage", metric.Name) + assert.Equal(t, GAUGE, metric.Type) + assert.Equal(t, 75.5, metric.Value) + assert.Equal(t, time.Second*10, metric.Interval) + assert.Equal(t, []string{"host", "cpu"}, metric.LabelKeys) + assert.Equal(t, []string{"localhost", "cpu0"}, metric.LabelVals) + }) + + t.Run("create counter metric", func(t *testing.T) { + metric := Metric{ + Name: "requests_total", + Type: COUNTER, + Value: 1000, + } + + assert.Equal(t, "requests_total", metric.Name) + assert.Equal(t, COUNTER, metric.Type) + assert.Equal(t, float64(1000), metric.Value) + }) + + t.Run("create untyped metric", func(t *testing.T) { + metric := Metric{ + Name: "unknown_metric", + Type: UNTYPED, + Value: 42, + } + + assert.Equal(t, "unknown_metric", metric.Name) + assert.Equal(t, UNTYPED, metric.Type) + assert.Equal(t, float64(42), metric.Value) + }) + + t.Run("create metric with empty labels", func(t *testing.T) { + metric := Metric{ + Name: "simple_metric", + Type: GAUGE, + Value: 123.45, + LabelKeys: []string{}, + LabelVals: []string{}, + } + + assert.Equal(t, "simple_metric", metric.Name) + assert.Empty(t, metric.LabelKeys) + assert.Empty(t, metric.LabelVals) + }) +} diff --git a/pkg/lib/time_test.go b/pkg/lib/time_test.go new file mode 100644 index 00000000..41cbafce --- /dev/null +++ b/pkg/lib/time_test.go @@ -0,0 +1,124 @@ +package lib + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEpochFromFormat(t *testing.T) { + t.Run("parse RFC3339 format", func(t *testing.T) { + ts := "2021-02-10T03:50:41Z" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 10, 3, 50, 41, 0, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse RFC3339 with timezone", func(t *testing.T) { + ts := "2021-02-10T03:50:41+00:00" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 10, 3, 50, 41, 0, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse RFC3339Nano format", func(t *testing.T) { + ts := "2021-02-10T03:50:41.123456789Z" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 10, 3, 50, 41, 123456789, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse custom RFC3339 format without Z", func(t *testing.T) { + ts := "2021-02-10T03:50:41.123456" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 10, 3, 50, 41, 123456000, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse ANSIC format", func(t *testing.T) { + ts := "Wed Feb 10 03:50:41 2021" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 10, 3, 50, 41, 0, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse ISO time format with space", func(t *testing.T) { + ts := "2021-02-10 03:50:41.123456" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 10, 3, 50, 41, 123456000, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse ISO time format without microseconds", func(t *testing.T) { + ts := "2021-02-10 03:50:41" + epoch := EpochFromFormat(ts) + // The isoTimeLayout is flexible and can parse without microseconds + expected := time.Date(2021, 2, 10, 3, 50, 41, 0, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("invalid format returns zero", func(t *testing.T) { + ts := "invalid-timestamp" + epoch := EpochFromFormat(ts) + assert.Equal(t, int64(0), epoch) + }) + + t.Run("empty string returns zero", func(t *testing.T) { + ts := "" + epoch := EpochFromFormat(ts) + assert.Equal(t, int64(0), epoch) + }) + + t.Run("parse timestamp with different year", func(t *testing.T) { + ts := "2020-09-14T16:12:49Z" + epoch := EpochFromFormat(ts) + expected := time.Date(2020, 9, 14, 16, 12, 49, 0, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse timestamp at epoch", func(t *testing.T) { + ts := "1970-01-01T00:00:00Z" + epoch := EpochFromFormat(ts) + assert.Equal(t, int64(0), epoch) + }) + + t.Run("parse timestamp with nanoseconds precision", func(t *testing.T) { + ts := "2021-02-11T21:43:11.180978123Z" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 11, 21, 43, 11, 180978123, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse timestamp with microseconds in custom format", func(t *testing.T) { + ts := "2021-02-11T21:43:11.180978" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 11, 21, 43, 11, 180978000, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("parse timestamp with microseconds in ISO format", func(t *testing.T) { + ts := "2021-02-11 21:43:11.180978" + epoch := EpochFromFormat(ts) + expected := time.Date(2021, 2, 11, 21, 43, 11, 180978000, time.UTC).Unix() + assert.Equal(t, expected, epoch) + }) + + t.Run("partial date string returns zero", func(t *testing.T) { + ts := "2021-02-10" + epoch := EpochFromFormat(ts) + assert.Equal(t, int64(0), epoch) + }) + + t.Run("numeric string returns zero", func(t *testing.T) { + ts := "1612928641" + epoch := EpochFromFormat(ts) + assert.Equal(t, int64(0), epoch) + }) + + t.Run("malformed RFC3339 returns zero", func(t *testing.T) { + ts := "2021-02-10T25:70:99Z" + epoch := EpochFromFormat(ts) + assert.Equal(t, int64(0), epoch) + }) +}