diff --git a/TODO.md b/TODO.md index 330f0c4..a6655e1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,8 @@ # TODO -- [ ] Unit tests -- [ ] HTTP Logging Middleware +- [ ] Pattern for extending config + +## Done +- [x] Unit tests +- [x] HTTP Logging Middleware +- [x] Fix panic with OTEL disabled diff --git a/pkg/app/setup_test.go b/pkg/app/setup_test.go new file mode 100644 index 0000000..4efaf6d --- /dev/null +++ b/pkg/app/setup_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "bufio" + "context" + "flag" + "io" + "os" + "regexp" + "testing" + + "github.com/rs/zerolog" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" +) + +func TestMustSetupConfigAndLogging(t *testing.T) { + // Configure app and logger + type inputs struct { + envs map[string]string + } + type outputs struct { + appName string + logLevel zerolog.Level + logRegexChecks []*regexp.Regexp + } + tests := []struct { + name string + inputs inputs + want outputs + }{ + { + name: "Test json logging with short timestamp", + inputs: inputs{ + envs: map[string]string{ + "APP_NAME": "testapp", + "APP_LOG_LEVEL": "warn", + "APP_LOG_FORMAT": "json", + "APP_LOG_TIME_FORMAT": "short", + }, + }, + want: outputs{ + appName: "testapp", + logLevel: zerolog.WarnLevel, + logRegexChecks: []*regexp.Regexp{ + regexp.MustCompile(`^\{.*time":"\d{1,}:\d{2}`), + }, + }, + }, + { + name: "Test json logging with unix timestamp", + inputs: inputs{ + envs: map[string]string{ + "APP_NAME": "testapp", + "APP_LOG_LEVEL": "info", + "APP_LOG_FORMAT": "json", + "APP_LOG_TIME_FORMAT": "unix", + }, + }, + want: outputs{ + appName: "testapp", + logLevel: zerolog.InfoLevel, + logRegexChecks: []*regexp.Regexp{ + regexp.MustCompile(`time":\d+,`), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables + for key, val := range tt.inputs.envs { + os.Setenv(key, val) + } + + // Prepare config in context + ctx := MustSetupConfigAndLogging(context.Background()) + + // Retrieve config and logger from prepared context + cfg := config.MustFromCtx(ctx) + logger := zerolog.Ctx(ctx) + + // Check wants + if cfg.Name != tt.want.appName { + t.Errorf("Expected app name %s, got %s", tt.want.appName, cfg.Name) + } + if logger.GetLevel() != tt.want.logLevel { + t.Errorf("Expected log level %#v, got %#v", tt.want.logLevel, logger.GetLevel()) + } + + // Send and capture a log + r, w := io.Pipe() + testLogger := logger.Output(w) + scanner := bufio.NewScanner(r) + + go func() { + testLogger.Error().Msg("test message") + w.Close() + }() + + logOut := make([]byte, 0) + if scanner.Scan() { + logOut = scanner.Bytes() + } + + // Check all expressions + for _, expr := range tt.want.logRegexChecks { + if !expr.Match(logOut) { + t.Errorf("Regex %s did not match log %s", expr.String(), logOut) + } + } + + // Super annoying need to reset due to app framework package + // using flag.Parse() and go test also using it + testlog := flag.Lookup("test.testlogfile").Value.String() + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) + flag.String("test.testlogfile", testlog, "") + flag.String("test.paniconexit0", "", "") + flag.String("test.v", "", "") + flag.Parse() + }) + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..d337a0f --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "encoding/json" + "testing" +) + +// Changing defaults could be a breaking change, +// if this needs to be modified to pass the test, +// an item should be added to the changelog. +// +// This should be maintained, as it is the primary +// interface between an app and the app framework. +var testDefaultConfig = &AppConfig{ + Environment: "development", + Version: getVersion(), + Logging: &LogConfig{ + Enabled: true, + Level: "info", + Format: LogFormatJSON, + Output: "stderr", + TimeFormat: TimeFormatLong, + }, + HTTP: &HTTPConfig{ + Listen: "127.0.0.1:8080", + LogRequests: false, + ReadTimeout: "10s", + WriteTimeout: "10s", + IdleTimeout: "1m", + }, + OTEL: &OTELConfig{ + Enabled: true, + PrometheusEnabled: true, + PrometheusPath: "/metrics", + StdoutEnabled: false, + MetricIntervalSecs: 30, + }, +} + +func Test_loadConfig(t *testing.T) { + type args struct { + configPath string + } + tests := []struct { + name string + args args + want *AppConfig + wantErr bool + }{ + { + name: "Ensure defaults", + args: args{configPath: ""}, + want: testDefaultConfig, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := loadConfig(tt.args.configPath) + if (err != nil) != tt.wantErr { + t.Errorf("loadConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Marshal both the expected and actual structs to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("Failed to marshal got to JSON: %v", err) + } + wantJSON, err := json.Marshal(tt.want) + if err != nil { + t.Fatalf("Failed to marshal want to JSON: %v", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + t.Errorf("loadConfig() JSON = %s, want JSON = %s", string(gotJSON), string(wantJSON)) + } + }) + } +} diff --git a/pkg/config/ctx_test.go b/pkg/config/ctx_test.go new file mode 100644 index 0000000..f3da19f --- /dev/null +++ b/pkg/config/ctx_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "context" + "testing" +) + +func TestFromCtx(t *testing.T) { + app := &AppConfig{ + Name: "testapp", + } + appCtx := app.AddToCtx(context.Background()) + + type args struct { + ctx context.Context + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Unprepared app context", + args: args{ctx: context.Background()}, + wantErr: true, + }, + { + name: "Prepared app context", + args: args{ctx: appCtx}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := FromCtx(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("FromCtx() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/pkg/otel/otel.go b/pkg/otel/otel.go index cb6cb32..6186d58 100644 --- a/pkg/otel/otel.go +++ b/pkg/otel/otel.go @@ -46,6 +46,11 @@ func Init(ctx context.Context) (context.Context, func(context.Context) error) { if !cfg.OTEL.Enabled { opentelemetry.SetMeterProvider(noopMetric.NewMeterProvider()) opentelemetry.SetTracerProvider(noop.NewTracerProvider()) + // Won't function with noop providers + meter := opentelemetry.Meter(cfg.Name) + ctx = AddMeterToCtx(ctx, meter) + tracer := opentelemetry.Tracer(cfg.Name) + ctx = AddTracerToCtx(ctx, tracer) return ctx, func(context.Context) error { return nil } @@ -62,12 +67,12 @@ func Init(ctx context.Context) (context.Context, func(context.Context) error) { if cfg.OTEL.StdoutEnabled { options = append(options, EnableStdoutExporter) } - if cfg.OTEL.MetricIntervalSecs > 0 { - metricInterval = time.Duration(cfg.OTEL.MetricIntervalSecs) * time.Second - } if cfg.OTEL.PrometheusEnabled { options = append(options, EnablePrometheusExporter) } + if cfg.OTEL.MetricIntervalSecs > 0 { + metricInterval = time.Duration(cfg.OTEL.MetricIntervalSecs) * time.Second + } options = append(options, WithMetricExportInterval(metricInterval))