| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "strings"
- "sync/atomic"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- )
- func TestOpsSystemLogSink_ShouldIndex(t *testing.T) {
- sink := &OpsSystemLogSink{}
- cases := []struct {
- name string
- event *logger.LogEvent
- want bool
- }{
- {
- name: "warn level",
- event: &logger.LogEvent{Level: "warn", Component: "app"},
- want: true,
- },
- {
- name: "error level",
- event: &logger.LogEvent{Level: "error", Component: "app"},
- want: true,
- },
- {
- name: "access component disabled by default",
- event: &logger.LogEvent{Level: "info", Component: "http.access"},
- want: false,
- },
- {
- name: "rejected access excluded from database sink",
- event: &logger.LogEvent{
- Level: "info",
- Component: "http.access",
- Fields: map[string]any{logger.OpsSystemLogSkipField: true},
- },
- want: false,
- },
- {
- name: "access component from fields disabled by default",
- event: &logger.LogEvent{
- Level: "info",
- Component: "",
- Fields: map[string]any{"component": "http.access"},
- },
- want: false,
- },
- {
- name: "audit component",
- event: &logger.LogEvent{Level: "info", Component: "audit.log_config_change"},
- want: true,
- },
- {
- name: "audit component from fields (real zap path)",
- event: &logger.LogEvent{
- Level: "info",
- Component: "",
- Fields: map[string]any{"component": "audit.log_config_change"},
- },
- want: true,
- },
- {
- name: "plain info",
- event: &logger.LogEvent{Level: "info", Component: "app"},
- want: false,
- },
- }
- for _, tc := range cases {
- if got := sink.shouldIndex(tc.event); got != tc.want {
- t.Fatalf("%s: shouldIndex()=%v, want %v", tc.name, got, tc.want)
- }
- }
- }
- func TestOpsSystemLogSink_ShouldIndexAccessLogsOnlyWhenEnabled(t *testing.T) {
- sink := &OpsSystemLogSink{}
- sink.SetPersistAccessLogs(true)
- if !sink.shouldIndex(&logger.LogEvent{Level: "info", Component: "http.access"}) {
- t.Fatal("access log should be indexed after explicit opt-in")
- }
- if !sink.shouldIndex(&logger.LogEvent{
- Level: "info",
- Fields: map[string]any{"component": "http.access"},
- }) {
- t.Fatal("field-based access log should be indexed after explicit opt-in")
- }
- if sink.shouldIndex(&logger.LogEvent{
- Level: "info",
- Component: "http.access",
- Fields: map[string]any{logger.OpsSystemLogSkipField: true},
- }) {
- t.Fatal("explicit skip marker must override access-log persistence")
- }
- }
- func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) {
- sink := &OpsSystemLogSink{
- queue: make(chan *logger.LogEvent, 1),
- }
- sink.WriteLogEvent(&logger.LogEvent{Level: "warn", Component: "app"})
- sink.WriteLogEvent(&logger.LogEvent{Level: "warn", Component: "app"})
- if got := len(sink.queue); got != 1 {
- t.Fatalf("queue len = %d, want 1", got)
- }
- if dropped := atomic.LoadUint64(&sink.droppedCount); dropped != 1 {
- t.Fatalf("droppedCount = %d, want 1", dropped)
- }
- }
- func TestOpsSystemLogSink_Health(t *testing.T) {
- sink := &OpsSystemLogSink{
- queue: make(chan *logger.LogEvent, 10),
- }
- sink.lastError.Store("db timeout")
- atomic.StoreUint64(&sink.droppedCount, 3)
- atomic.StoreUint64(&sink.writeFailed, 2)
- atomic.StoreUint64(&sink.writtenCount, 5)
- atomic.StoreUint64(&sink.totalDelayNs, uint64(5000000)) // 5ms total -> avg 1ms
- sink.queue <- &logger.LogEvent{Level: "warn", Component: "app"}
- sink.queue <- &logger.LogEvent{Level: "warn", Component: "app"}
- health := sink.Health()
- if health.QueueDepth != 2 {
- t.Fatalf("queue depth = %d, want 2", health.QueueDepth)
- }
- if health.QueueCapacity != 10 {
- t.Fatalf("queue capacity = %d, want 10", health.QueueCapacity)
- }
- if health.DroppedCount != 3 {
- t.Fatalf("dropped = %d, want 3", health.DroppedCount)
- }
- if health.WriteFailed != 2 {
- t.Fatalf("write failed = %d, want 2", health.WriteFailed)
- }
- if health.WrittenCount != 5 {
- t.Fatalf("written = %d, want 5", health.WrittenCount)
- }
- if health.AvgWriteDelayMs != 1 {
- t.Fatalf("avg delay ms = %d, want 1", health.AvgWriteDelayMs)
- }
- if health.LastError != "db timeout" {
- t.Fatalf("last error = %q, want db timeout", health.LastError)
- }
- }
- func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) {
- done := make(chan struct{}, 1)
- var captured []*OpsInsertSystemLogInput
- repo := &opsRepoMock{
- BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
- captured = append(captured, inputs...)
- select {
- case done <- struct{}{}:
- default:
- }
- return int64(len(inputs)), nil
- },
- }
- sink := NewOpsSystemLogSink(repo)
- sink.host = "api-node-1"
- sink.batchSize = 1
- sink.flushInterval = 10 * time.Millisecond
- sink.Start()
- defer sink.Stop()
- sink.WriteLogEvent(&logger.LogEvent{
- Time: time.Now().UTC(),
- Level: "warn",
- Component: "http.access",
- Message: `authorization="Bearer sk-test-123"`,
- Fields: map[string]any{
- "component": "http.access",
- "request_id": "req-1",
- "client_request_id": "creq-1",
- "user_id": "12",
- "api_key_id": int64(56),
- "account_id": json.Number("34"),
- "platform": "openai",
- "model": "gpt-5",
- },
- })
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatalf("timeout waiting for sink flush")
- }
- if len(captured) != 1 {
- t.Fatalf("captured len = %d, want 1", len(captured))
- }
- item := captured[0]
- if item.Host != "api-node-1" {
- t.Fatalf("host = %q, want api-node-1", item.Host)
- }
- if item.RequestID != "req-1" || item.ClientRequestID != "creq-1" {
- t.Fatalf("unexpected request ids: %+v", item)
- }
- if item.UserID == nil || *item.UserID != 12 {
- t.Fatalf("unexpected user_id: %+v", item.UserID)
- }
- if item.APIKeyID == nil || *item.APIKeyID != 56 {
- t.Fatalf("unexpected api_key_id: %+v", item.APIKeyID)
- }
- if item.AccountID == nil || *item.AccountID != 34 {
- t.Fatalf("unexpected account_id: %+v", item.AccountID)
- }
- if strings.TrimSpace(item.Message) == "" {
- t.Fatalf("message should not be empty")
- }
- // writtenCount is incremented after BatchInsertSystemLogsFn returns,
- // so poll briefly to avoid a race between the done signal and the atomic add.
- deadline := time.Now().Add(time.Second)
- for time.Now().Before(deadline) {
- if sink.Health().WrittenCount > 0 {
- break
- }
- time.Sleep(time.Millisecond)
- }
- health := sink.Health()
- if health.WrittenCount == 0 {
- t.Fatalf("written_count should be >0")
- }
- }
- func TestOpsSystemLogSink_FlushFailureUpdatesHealth(t *testing.T) {
- repo := &opsRepoMock{
- BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
- return 0, errors.New("db unavailable")
- },
- }
- sink := NewOpsSystemLogSink(repo)
- sink.batchSize = 1
- sink.flushInterval = 10 * time.Millisecond
- sink.Start()
- defer sink.Stop()
- sink.WriteLogEvent(&logger.LogEvent{
- Time: time.Now().UTC(),
- Level: "warn",
- Component: "app",
- Message: "boom",
- Fields: map[string]any{},
- })
- deadline := time.Now().Add(2 * time.Second)
- for time.Now().Before(deadline) {
- health := sink.Health()
- if health.WriteFailed > 0 {
- if !strings.Contains(health.LastError, "db unavailable") {
- t.Fatalf("unexpected last error: %s", health.LastError)
- }
- return
- }
- time.Sleep(20 * time.Millisecond)
- }
- t.Fatalf("write_failed_count not updated")
- }
- func TestOpsSystemLogSink_StopFlushUsesActiveContextAndDrainsQueue(t *testing.T) {
- var inserted int64
- var canceledCtxCalls int64
- repo := &opsRepoMock{
- BatchInsertSystemLogsFn: func(ctx context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
- if err := ctx.Err(); err != nil {
- atomic.AddInt64(&canceledCtxCalls, 1)
- return 0, err
- }
- atomic.AddInt64(&inserted, int64(len(inputs)))
- return int64(len(inputs)), nil
- },
- }
- sink := NewOpsSystemLogSink(repo)
- sink.batchSize = 200
- sink.flushInterval = time.Hour
- sink.Start()
- sink.WriteLogEvent(&logger.LogEvent{
- Time: time.Now().UTC(),
- Level: "warn",
- Component: "app",
- Message: "pending-on-shutdown",
- Fields: map[string]any{"component": "http.access"},
- })
- sink.Stop()
- if got := atomic.LoadInt64(&inserted); got != 1 {
- t.Fatalf("inserted = %d, want 1", got)
- }
- if got := atomic.LoadInt64(&canceledCtxCalls); got != 0 {
- t.Fatalf("canceled ctx calls = %d, want 0", got)
- }
- health := sink.Health()
- if health.WrittenCount != 1 {
- t.Fatalf("written_count = %d, want 1", health.WrittenCount)
- }
- }
- type stringerValue string
- func (s stringerValue) String() string { return string(s) }
- func TestOpsSystemLogSink_HelperFunctions(t *testing.T) {
- src := map[string]any{"a": 1}
- cloned := copyMap(src)
- src["a"] = 2
- v, ok := cloned["a"].(int)
- if !ok || v != 1 {
- t.Fatalf("copyMap should create copy")
- }
- if got := asString(stringerValue(" hello ")); got != "hello" {
- t.Fatalf("asString stringer = %q", got)
- }
- if got := asString(fmt.Errorf("x")); got != "" {
- t.Fatalf("asString error should be empty, got %q", got)
- }
- if got := asString(123); got != "" {
- t.Fatalf("asString non-string should be empty, got %q", got)
- }
- cases := []struct {
- in any
- want int64
- ok bool
- }{
- {in: 5, want: 5, ok: true},
- {in: int64(6), want: 6, ok: true},
- {in: float64(7), want: 7, ok: true},
- {in: json.Number("8"), want: 8, ok: true},
- {in: "9", want: 9, ok: true},
- {in: "0", ok: false},
- {in: -1, ok: false},
- {in: "abc", ok: false},
- }
- for _, tc := range cases {
- got := asInt64Ptr(tc.in)
- if tc.ok {
- if got == nil || *got != tc.want {
- t.Fatalf("asInt64Ptr(%v) = %+v, want %d", tc.in, got, tc.want)
- }
- } else if got != nil {
- t.Fatalf("asInt64Ptr(%v) should be nil, got %d", tc.in, *got)
- }
- }
- }
- func TestNormalizeSystemLogHost(t *testing.T) {
- if got := normalizeSystemLogHost(" api-node-1 ", nil); got != "api-node-1" {
- t.Fatalf("trimmed host = %q, want api-node-1", got)
- }
- if got := normalizeSystemLogHost("", nil); got != "unknown" {
- t.Fatalf("empty host = %q, want unknown", got)
- }
- if got := normalizeSystemLogHost("api-node-1", errors.New("hostname unavailable")); got != "unknown" {
- t.Fatalf("errored host = %q, want unknown", got)
- }
- longHost := strings.Repeat("节", maxSystemLogHostLength+1)
- got := normalizeSystemLogHost(longHost, nil)
- if runeCount := len([]rune(got)); runeCount != maxSystemLogHostLength {
- t.Fatalf("truncated host rune count = %d, want %d", runeCount, maxSystemLogHostLength)
- }
- }
|