test(ops): 提升日志链路覆盖率并修复lint阻塞

This commit is contained in:
yangjianbo
2026-02-12 16:25:44 +08:00
parent 208c5380f4
commit a5f29019d9
4 changed files with 1060 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type responseEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func newOpsSystemLogTestRouter(handler *OpsHandler, withUser bool) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
if withUser {
r.Use(func(c *gin.Context) {
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 99})
c.Next()
})
}
r.GET("/logs", handler.ListSystemLogs)
r.POST("/logs/cleanup", handler.CleanupSystemLogs)
r.GET("/logs/health", handler.GetSystemLogIngestionHealth)
return r
}
func TestOpsSystemLogHandler_ListUnavailable(t *testing.T) {
h := NewOpsHandler(nil)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503", w.Code)
}
}
func TestOpsSystemLogHandler_ListInvalidUserID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs?user_id=abc", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_ListInvalidAccountID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs?account_id=-1", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_ListMonitoringDisabled(t *testing.T) {
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
}, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d, want 404", w.Code)
}
}
func TestOpsSystemLogHandler_ListSuccess(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs?time_range=30m&page=1&page_size=20", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d, want 200", w.Code)
}
var resp responseEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Code != 0 {
t.Fatalf("unexpected response code: %+v", resp)
}
}
func TestOpsSystemLogHandler_CleanupUnauthorized(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"request_id":"r1"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupInvalidPayload(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{bad-json`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupInvalidTime(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"start_time":"bad","request_id":"r1"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupInvalidEndTime(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"end_time":"bad","request_id":"r1"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupServiceUnavailable(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"request_id":"r1"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupMonitoringDisabled(t *testing.T) {
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
}, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"request_id":"r1"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d, want 404", w.Code)
}
}
func TestOpsSystemLogHandler_Health(t *testing.T) {
sink := service.NewOpsSystemLogSink(nil)
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sink)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs/health", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d, want 200", w.Code)
}
}
func TestOpsSystemLogHandler_HealthUnavailableAndMonitoringDisabled(t *testing.T) {
h := NewOpsHandler(nil)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs/health", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503", w.Code)
}
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
}, nil, nil, nil, nil, nil, nil, nil, nil)
h = NewOpsHandler(svc)
r = newOpsSystemLogTestRouter(h, false)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/logs/health", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d, want 404", w.Code)
}
}

View File

@@ -0,0 +1,570 @@
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())
}

View File

@@ -68,6 +68,20 @@ type OpsMetricThresholds struct {
UpstreamErrorRatePercentMax *float64 `json:"upstream_error_rate_percent_max,omitempty"` // 上游错误率高于此值变红
}
type OpsRuntimeLogConfig struct {
Level string `json:"level"`
EnableSampling bool `json:"enable_sampling"`
SamplingInitial int `json:"sampling_initial"`
SamplingNext int `json:"sampling_thereafter"`
Caller bool `json:"caller"`
StacktraceLevel string `json:"stacktrace_level"`
RetentionDays int `json:"retention_days"`
Source string `json:"source,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
UpdatedByUserID int64 `json:"updated_by_user_id,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
type OpsAlertRuntimeSettings struct {
EvaluationIntervalSeconds int `json:"evaluation_interval_seconds"`

View File

@@ -0,0 +1,243 @@
package service
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
)
func TestOpsServiceListSystemLogs_DefaultClampAndSuccess(t *testing.T) {
var gotFilter *OpsSystemLogFilter
repo := &opsRepoMock{
ListSystemLogsFn: func(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error) {
gotFilter = filter
return &OpsSystemLogList{
Logs: []*OpsSystemLog{{ID: 1, Level: "warn", Message: "x"}},
Total: 1,
Page: filter.Page,
PageSize: filter.PageSize,
}, nil
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
out, err := svc.ListSystemLogs(context.Background(), &OpsSystemLogFilter{
Page: 0,
PageSize: 999,
})
if err != nil {
t.Fatalf("ListSystemLogs() error: %v", err)
}
if gotFilter == nil {
t.Fatalf("expected repository to receive filter")
}
if gotFilter.Page != 1 || gotFilter.PageSize != 200 {
t.Fatalf("filter normalized unexpectedly: page=%d pageSize=%d", gotFilter.Page, gotFilter.PageSize)
}
if out.Total != 1 || len(out.Logs) != 1 {
t.Fatalf("unexpected result: %+v", out)
}
}
func TestOpsServiceListSystemLogs_MonitoringDisabled(t *testing.T) {
svc := NewOpsService(
&opsRepoMock{},
nil,
&config.Config{Ops: config.OpsConfig{Enabled: false}},
nil, nil, nil, nil, nil, nil, nil, nil,
)
_, err := svc.ListSystemLogs(context.Background(), &OpsSystemLogFilter{})
if err == nil {
t.Fatalf("expected disabled error")
}
}
func TestOpsServiceListSystemLogs_NilRepoReturnsEmpty(t *testing.T) {
svc := NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
out, err := svc.ListSystemLogs(context.Background(), nil)
if err != nil {
t.Fatalf("ListSystemLogs() error: %v", err)
}
if out == nil || out.Page != 1 || out.PageSize != 50 || out.Total != 0 || len(out.Logs) != 0 {
t.Fatalf("unexpected nil-repo result: %+v", out)
}
}
func TestOpsServiceListSystemLogs_RepoErrorMapped(t *testing.T) {
repo := &opsRepoMock{
ListSystemLogsFn: func(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error) {
return nil, errors.New("db down")
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
_, err := svc.ListSystemLogs(context.Background(), &OpsSystemLogFilter{})
if err == nil {
t.Fatalf("expected mapped internal error")
}
if !strings.Contains(err.Error(), "OPS_SYSTEM_LOG_LIST_FAILED") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestOpsServiceCleanupSystemLogs_SuccessAndAudit(t *testing.T) {
var audit *OpsSystemLogCleanupAudit
repo := &opsRepoMock{
DeleteSystemLogsFn: func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error) {
return 3, nil
},
InsertSystemLogCleanupAuditFn: func(ctx context.Context, input *OpsSystemLogCleanupAudit) error {
audit = input
return nil
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
userID := int64(7)
now := time.Now().UTC()
filter := &OpsSystemLogCleanupFilter{
StartTime: &now,
Level: "warn",
RequestID: "req-1",
ClientRequestID: "creq-1",
UserID: &userID,
Query: "timeout",
}
deleted, err := svc.CleanupSystemLogs(context.Background(), filter, 99)
if err != nil {
t.Fatalf("CleanupSystemLogs() error: %v", err)
}
if deleted != 3 {
t.Fatalf("deleted=%d, want 3", deleted)
}
if audit == nil {
t.Fatalf("expected cleanup audit")
}
if !strings.Contains(audit.Conditions, `"client_request_id":"creq-1"`) {
t.Fatalf("audit conditions should include client_request_id: %s", audit.Conditions)
}
if !strings.Contains(audit.Conditions, `"user_id":7`) {
t.Fatalf("audit conditions should include user_id: %s", audit.Conditions)
}
}
func TestOpsServiceCleanupSystemLogs_RepoUnavailableAndInvalidOperator(t *testing.T) {
svc := NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
if _, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{RequestID: "r"}, 1); err == nil {
t.Fatalf("expected repo unavailable error")
}
svc = NewOpsService(&opsRepoMock{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
if _, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{RequestID: "r"}, 0); err == nil {
t.Fatalf("expected invalid operator error")
}
}
func TestOpsServiceCleanupSystemLogs_FilterRequired(t *testing.T) {
repo := &opsRepoMock{
DeleteSystemLogsFn: func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error) {
return 0, errors.New("cleanup requires at least one filter condition")
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
_, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{}, 1)
if err == nil {
t.Fatalf("expected filter required error")
}
if !strings.Contains(strings.ToLower(err.Error()), "filter") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestOpsServiceCleanupSystemLogs_InvalidRange(t *testing.T) {
repo := &opsRepoMock{}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
start := time.Now().UTC()
end := start.Add(-time.Hour)
_, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{
StartTime: &start,
EndTime: &end,
}, 1)
if err == nil {
t.Fatalf("expected invalid range error")
}
}
func TestOpsServiceCleanupSystemLogs_NoRowsAndInternalError(t *testing.T) {
repo := &opsRepoMock{
DeleteSystemLogsFn: func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error) {
return 0, sql.ErrNoRows
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
deleted, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{
RequestID: "req-1",
}, 1)
if err != nil || deleted != 0 {
t.Fatalf("expected no rows shortcut, deleted=%d err=%v", deleted, err)
}
repo.DeleteSystemLogsFn = func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error) {
return 0, errors.New("boom")
}
if _, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{
RequestID: "req-1",
}, 1); err == nil {
t.Fatalf("expected internal cleanup error")
}
}
func TestOpsServiceCleanupSystemLogs_AuditFailureIgnored(t *testing.T) {
repo := &opsRepoMock{
DeleteSystemLogsFn: func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error) {
return 5, nil
},
InsertSystemLogCleanupAuditFn: func(ctx context.Context, input *OpsSystemLogCleanupAudit) error {
return errors.New("audit down")
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
deleted, err := svc.CleanupSystemLogs(context.Background(), &OpsSystemLogCleanupFilter{
RequestID: "r1",
}, 1)
if err != nil || deleted != 5 {
t.Fatalf("audit failure should not break cleanup, deleted=%d err=%v", deleted, err)
}
}
func TestMarshalSystemLogCleanupConditions_NilAndMarshalError(t *testing.T) {
if got := marshalSystemLogCleanupConditions(nil); got != "{}" {
t.Fatalf("nil filter should return {}, got %s", got)
}
now := time.Now().UTC()
userID := int64(1)
filter := &OpsSystemLogCleanupFilter{
StartTime: &now,
EndTime: &now,
UserID: &userID,
}
got := marshalSystemLogCleanupConditions(filter)
if !strings.Contains(got, `"start_time"`) || !strings.Contains(got, `"user_id":1`) {
t.Fatalf("unexpected marshal payload: %s", got)
}
}
func TestOpsServiceGetSystemLogSinkHealth(t *testing.T) {
svc := NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
health := svc.GetSystemLogSinkHealth()
if health.QueueCapacity != 0 || health.QueueDepth != 0 {
t.Fatalf("unexpected health for nil sink: %+v", health)
}
sink := NewOpsSystemLogSink(&opsRepoMock{})
svc = NewOpsService(&opsRepoMock{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, sink)
health = svc.GetSystemLogSinkHealth()
if health.QueueCapacity <= 0 {
t.Fatalf("expected non-zero queue capacity: %+v", health)
}
}