| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607 |
- 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
- getValueCalls int
- getMultipleCalls int
- getValueFn func(key string) (string, error)
- setFn func(key, value string) error
- deleteFn func(key string) error
- }
- type runtimeCleanupReloader struct {
- calls int
- }
- func (r *runtimeCleanupReloader) Reload(context.Context) error {
- r.calls++
- return nil
- }
- 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) {
- s.getValueCalls++
- 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) {
- s.getMultipleCalls++
- 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: 7,
- SystemLogRetentionDays: 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,
- systemLogSink: &OpsSystemLogSink{},
- 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",
- PersistAccessLogs: true,
- 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())
- }
- if svc.systemLogSink.persistAccessLogs.Load() {
- t.Fatal("access-log persistence should rollback after setting write failure")
- }
- }
- func TestApplyRuntimeLogConfigOnStartup(t *testing.T) {
- repo := newRuntimeSettingRepoStub()
- cfgRaw := `{"level":"debug","persist_access_logs":true,"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,
- systemLogSink: &OpsSystemLogSink{},
- 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())
- }
- if !svc.systemLogSink.persistAccessLogs.Load() {
- t.Fatal("startup config did not enable access-log persistence")
- }
- }
- 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,
- SystemLogRetentionDays: 14,
- },
- },
- })
- if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 14 {
- t.Fatalf("unexpected defaults: %+v", defaults)
- }
- if defaults.PersistAccessLogs {
- t.Fatal("access-log persistence should be disabled by default")
- }
- 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 != 14 {
- 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()
- reloader := &runtimeCleanupReloader{}
- svc := &OpsService{
- settingRepo: repo,
- systemLogSink: &OpsSystemLogSink{},
- cleanupReloader: reloader,
- 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",
- PersistAccessLogs: true,
- 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())
- }
- if !svc.systemLogSink.persistAccessLogs.Load() {
- t.Fatal("runtime update did not enable access-log persistence")
- }
- if reloader.calls != 1 {
- t.Fatalf("cleanup reload calls = %d, want 1", reloader.calls)
- }
- }
- 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())
- }
|