571 lines
15 KiB
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())
|
|
}
|