Files
sub2api/backend/internal/service/ops_log_runtime_test.go

571 lines
15 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
type runtimeSettingRepoStub struct {
values map[string]string
deleted map[string]bool
setCalls int
getValueFn func(key string) (string, error)
setFn func(key, value string) error
deleteFn func(key string) error
}
func newRuntimeSettingRepoStub() *runtimeSettingRepoStub {
return &runtimeSettingRepoStub{
values: map[string]string{},
deleted: map[string]bool{},
}
}
func (s *runtimeSettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
value, err := s.GetValue(ctx, key)
if err != nil {
return nil, err
}
return &Setting{Key: key, Value: value}, nil
}
func (s *runtimeSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
if s.getValueFn != nil {
return s.getValueFn(key)
}
value, ok := s.values[key]
if !ok {
return "", ErrSettingNotFound
}
return value, nil
}
func (s *runtimeSettingRepoStub) Set(_ context.Context, key, value string) error {
if s.setFn != nil {
if err := s.setFn(key, value); err != nil {
return err
}
}
s.values[key] = value
s.setCalls++
return nil
}
func (s *runtimeSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *runtimeSettingRepoStub) SetMultiple(_ context.Context, settings map[string]string) error {
for key, value := range settings {
s.values[key] = value
}
return nil
}
func (s *runtimeSettingRepoStub) GetAll(_ context.Context) (map[string]string, error) {
out := make(map[string]string, len(s.values))
for key, value := range s.values {
out[key] = value
}
return out, nil
}
func (s *runtimeSettingRepoStub) Delete(_ context.Context, key string) error {
if s.deleteFn != nil {
if err := s.deleteFn(key); err != nil {
return err
}
}
if _, ok := s.values[key]; !ok {
return ErrSettingNotFound
}
delete(s.values, key)
s.deleted[key] = true
return nil
}
func TestUpdateRuntimeLogConfig_InvalidConfigShouldNotApply(t *testing.T) {
repo := newRuntimeSettingRepoStub()
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "info",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
if err := logger.Init(logger.InitOptions{
Level: "info",
Format: "json",
ServiceName: "sub2api",
Environment: "test",
Output: logger.OutputOptions{
ToStdout: true,
ToFile: false,
},
}); err != nil {
t.Fatalf("init logger: %v", err)
}
_, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{
Level: "trace",
EnableSampling: true,
SamplingInitial: 100,
SamplingNext: 100,
Caller: true,
StacktraceLevel: "error",
RetentionDays: 30,
}, 1)
if err == nil {
t.Fatalf("expected validation error")
}
if logger.CurrentLevel() != "info" {
t.Fatalf("logger level changed unexpectedly: %s", logger.CurrentLevel())
}
if repo.setCalls != 1 {
// GetRuntimeLogConfig() 会在 key 缺失时写入默认值,此处应只有这一次持久化。
t.Fatalf("unexpected set calls: %d", repo.setCalls)
}
}
func TestResetRuntimeLogConfig_ShouldFallbackToBaseline(t *testing.T) {
repo := newRuntimeSettingRepoStub()
existing := &OpsRuntimeLogConfig{
Level: "debug",
EnableSampling: true,
SamplingInitial: 50,
SamplingNext: 50,
Caller: true,
StacktraceLevel: "error",
RetentionDays: 60,
Source: "runtime_setting",
}
raw, _ := json.Marshal(existing)
repo.values[SettingKeyOpsRuntimeLogConfig] = string(raw)
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "warn",
Caller: false,
StacktraceLevel: "fatal",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
Ops: config.OpsConfig{
Cleanup: config.OpsCleanupConfig{
ErrorLogRetentionDays: 45,
},
},
},
}
if err := logger.Init(logger.InitOptions{
Level: "debug",
Format: "json",
ServiceName: "sub2api",
Environment: "test",
Output: logger.OutputOptions{
ToStdout: true,
ToFile: false,
},
}); err != nil {
t.Fatalf("init logger: %v", err)
}
resetCfg, err := svc.ResetRuntimeLogConfig(context.Background(), 9)
if err != nil {
t.Fatalf("ResetRuntimeLogConfig() error: %v", err)
}
if resetCfg.Source != "baseline" {
t.Fatalf("source = %q, want baseline", resetCfg.Source)
}
if resetCfg.Level != "warn" {
t.Fatalf("level = %q, want warn", resetCfg.Level)
}
if resetCfg.RetentionDays != 45 {
t.Fatalf("retention_days = %d, want 45", resetCfg.RetentionDays)
}
if logger.CurrentLevel() != "warn" {
t.Fatalf("logger level = %q, want warn", logger.CurrentLevel())
}
if !repo.deleted[SettingKeyOpsRuntimeLogConfig] {
t.Fatalf("runtime setting key should be deleted")
}
}
func TestResetRuntimeLogConfig_InvalidOperator(t *testing.T) {
svc := &OpsService{settingRepo: newRuntimeSettingRepoStub()}
_, err := svc.ResetRuntimeLogConfig(context.Background(), 0)
if err == nil {
t.Fatalf("expected invalid operator error")
}
if err.Error() != "invalid operator id" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetRuntimeLogConfig_InvalidJSONFallback(t *testing.T) {
repo := newRuntimeSettingRepoStub()
repo.values[SettingKeyOpsRuntimeLogConfig] = `{invalid-json}`
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "warn",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
got, err := svc.GetRuntimeLogConfig(context.Background())
if err != nil {
t.Fatalf("GetRuntimeLogConfig() error: %v", err)
}
if got.Level != "warn" {
t.Fatalf("level = %q, want warn", got.Level)
}
}
func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) {
repo := newRuntimeSettingRepoStub()
oldCfg := &OpsRuntimeLogConfig{
Level: "info",
EnableSampling: false,
SamplingInitial: 100,
SamplingNext: 100,
Caller: true,
StacktraceLevel: "error",
RetentionDays: 30,
}
raw, _ := json.Marshal(oldCfg)
repo.values[SettingKeyOpsRuntimeLogConfig] = string(raw)
repo.setFn = func(key, value string) error {
if key == SettingKeyOpsRuntimeLogConfig {
return errors.New("db down")
}
return nil
}
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "info",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
if err := logger.Init(logger.InitOptions{
Level: "info",
Format: "json",
ServiceName: "sub2api",
Environment: "test",
Output: logger.OutputOptions{
ToStdout: true,
ToFile: false,
},
}); err != nil {
t.Fatalf("init logger: %v", err)
}
_, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{
Level: "debug",
EnableSampling: false,
SamplingInitial: 100,
SamplingNext: 100,
Caller: true,
StacktraceLevel: "error",
RetentionDays: 30,
}, 5)
if err == nil {
t.Fatalf("expected persist error")
}
// Persist failure should rollback runtime level back to old effective level.
if logger.CurrentLevel() != "info" {
t.Fatalf("logger level should rollback to info, got %s", logger.CurrentLevel())
}
}
func TestApplyRuntimeLogConfigOnStartup(t *testing.T) {
repo := newRuntimeSettingRepoStub()
cfgRaw := `{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}`
repo.values[SettingKeyOpsRuntimeLogConfig] = cfgRaw
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "info",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
if err := logger.Init(logger.InitOptions{
Level: "info",
Format: "json",
ServiceName: "sub2api",
Environment: "test",
Output: logger.OutputOptions{
ToStdout: true,
ToFile: false,
},
}); err != nil {
t.Fatalf("init logger: %v", err)
}
svc.applyRuntimeLogConfigOnStartup(context.Background())
if logger.CurrentLevel() != "debug" {
t.Fatalf("expected startup apply debug, got %s", logger.CurrentLevel())
}
}
func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) {
defaults := defaultOpsRuntimeLogConfig(&config.Config{
Log: config.LogConfig{
Level: "DEBUG",
Caller: false,
StacktraceLevel: "FATAL",
Sampling: config.LogSamplingConfig{
Enabled: true,
Initial: 50,
Thereafter: 20,
},
},
Ops: config.OpsConfig{
Cleanup: config.OpsCleanupConfig{
ErrorLogRetentionDays: 7,
},
},
})
if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 7 {
t.Fatalf("unexpected defaults: %+v", defaults)
}
cfg := &OpsRuntimeLogConfig{
Level: " ",
EnableSampling: true,
SamplingInitial: 0,
SamplingNext: -1,
Caller: true,
StacktraceLevel: "",
RetentionDays: 0,
}
normalizeOpsRuntimeLogConfig(cfg, defaults)
if cfg.Level != "debug" || cfg.StacktraceLevel != "fatal" {
t.Fatalf("normalize level/stacktrace failed: %+v", cfg)
}
if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 7 {
t.Fatalf("normalize numeric defaults failed: %+v", cfg)
}
if err := validateOpsRuntimeLogConfig(cfg); err != nil {
t.Fatalf("validate normalized config should pass: %v", err)
}
}
func TestValidateRuntimeLogConfigErrors(t *testing.T) {
cases := []struct {
name string
cfg *OpsRuntimeLogConfig
}{
{name: "nil", cfg: nil},
{name: "bad level", cfg: &OpsRuntimeLogConfig{Level: "trace", StacktraceLevel: "error", SamplingInitial: 1, SamplingNext: 1, RetentionDays: 1}},
{name: "bad stack", cfg: &OpsRuntimeLogConfig{Level: "info", StacktraceLevel: "warn", SamplingInitial: 1, SamplingNext: 1, RetentionDays: 1}},
{name: "bad initial", cfg: &OpsRuntimeLogConfig{Level: "info", StacktraceLevel: "error", SamplingInitial: 0, SamplingNext: 1, RetentionDays: 1}},
{name: "bad next", cfg: &OpsRuntimeLogConfig{Level: "info", StacktraceLevel: "error", SamplingInitial: 1, SamplingNext: 0, RetentionDays: 1}},
{name: "bad retention", cfg: &OpsRuntimeLogConfig{Level: "info", StacktraceLevel: "error", SamplingInitial: 1, SamplingNext: 1, RetentionDays: 0}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateOpsRuntimeLogConfig(tc.cfg); err == nil {
t.Fatalf("expected validation error")
}
})
}
}
func TestGetRuntimeLogConfigFallbackAndErrors(t *testing.T) {
var nilSvc *OpsService
cfg, err := nilSvc.GetRuntimeLogConfig(context.Background())
if err != nil {
t.Fatalf("nil svc should fallback default: %v", err)
}
if cfg.Level != "info" {
t.Fatalf("unexpected nil svc default level: %s", cfg.Level)
}
repo := newRuntimeSettingRepoStub()
repo.getValueFn = func(key string) (string, error) {
return "", errors.New("boom")
}
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "warn",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
if _, err := svc.GetRuntimeLogConfig(context.Background()); err == nil {
t.Fatalf("expected get value error")
}
}
func TestUpdateRuntimeLogConfig_PreconditionErrors(t *testing.T) {
svc := &OpsService{}
if _, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{}, 1); err == nil {
t.Fatalf("expected setting repo not initialized")
}
svc = &OpsService{settingRepo: newRuntimeSettingRepoStub()}
if _, err := svc.UpdateRuntimeLogConfig(context.Background(), nil, 1); err == nil {
t.Fatalf("expected invalid config")
}
if _, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{
Level: "info",
StacktraceLevel: "error",
SamplingInitial: 1,
SamplingNext: 1,
RetentionDays: 1,
}, 0); err == nil {
t.Fatalf("expected invalid operator")
}
}
func TestUpdateRuntimeLogConfig_Success(t *testing.T) {
repo := newRuntimeSettingRepoStub()
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "info",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
if err := logger.Init(logger.InitOptions{
Level: "info",
Format: "json",
ServiceName: "sub2api",
Environment: "test",
Output: logger.OutputOptions{
ToStdout: true,
ToFile: false,
},
}); err != nil {
t.Fatalf("init logger: %v", err)
}
next, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{
Level: "debug",
EnableSampling: false,
SamplingInitial: 100,
SamplingNext: 100,
Caller: true,
StacktraceLevel: "error",
RetentionDays: 30,
}, 2)
if err != nil {
t.Fatalf("UpdateRuntimeLogConfig() error: %v", err)
}
if next.Source != "runtime_setting" || next.UpdatedByUserID != 2 || next.UpdatedAt == "" {
t.Fatalf("unexpected metadata: %+v", next)
}
if logger.CurrentLevel() != "debug" {
t.Fatalf("expected applied level debug, got %s", logger.CurrentLevel())
}
}
func TestResetRuntimeLogConfig_IgnoreNotFoundDelete(t *testing.T) {
repo := newRuntimeSettingRepoStub()
repo.deleteFn = func(key string) error { return ErrSettingNotFound }
svc := &OpsService{
settingRepo: repo,
cfg: &config.Config{
Log: config.LogConfig{
Level: "info",
Caller: true,
StacktraceLevel: "error",
Sampling: config.LogSamplingConfig{
Enabled: false,
Initial: 100,
Thereafter: 100,
},
},
},
}
if _, err := svc.ResetRuntimeLogConfig(context.Background(), 1); err != nil {
t.Fatalf("reset should ignore ErrSettingNotFound: %v", err)
}
}
func TestApplyRuntimeLogConfigHelpers(t *testing.T) {
if err := applyOpsRuntimeLogConfig(nil); err == nil {
t.Fatalf("expected nil config error")
}
normalizeOpsRuntimeLogConfig(nil, &OpsRuntimeLogConfig{Level: "info"})
normalizeOpsRuntimeLogConfig(&OpsRuntimeLogConfig{Level: "debug"}, nil)
var nilSvc *OpsService
nilSvc.applyRuntimeLogConfigOnStartup(context.Background())
}