diff --git a/log/zap/buffered_write_syncer.go b/log/zap/buffered_write_syncer.go new file mode 100644 index 0000000000000000000000000000000000000000..6e2edc6f5d8588c4246b85f6722531b558df163c --- /dev/null +++ b/log/zap/buffered_write_syncer.go @@ -0,0 +1,181 @@ +package zap + +import ( + "context" + "sync" + "time" + + "git.perx.ru/perxis/perxis-go/log" + "git.perx.ru/perxis/perxis-go/pkg/errors" +) + +const ( + defaultMaxBufferSize = 1000 + defaultMaxSyncQueueSize = 16 + defaultFlushInterval = 5 * time.Second +) + +var SyncQueueOverflow = errors.New("sync queue overflow") + +// BufferedWriteSyncer это WriteSyncer, который отправляет записи в log.Service. +// Когда количество буферизированных записей достигает некоторого предела или проходит определенный фиксированный интервал, +// записи отправляются в очередь для синхронизации с log.Service. +type BufferedWriteSyncer struct { + // FlushInterval устанавливает интервал, через который буферизированные записи будут отправлены на синхронизацию. + // + // Значение по умолчанию для этого параметра равно 5 секунд. + FlushInterval time.Duration + + // MaxBufferSize устанавливает максимальное количество записей, которые могут быть буферизованы. + // Когда количество буферизованных записей превысит этот порог, они будут отправлены на синхронизацию в log.Service. + // + // Значение по умолчанию для этого параметра равно 1000. + MaxBufferSize int + + // MaxSyncQueueSize устанавливает максимальный размер очереди записей на синхронизацию с log.Service. + // + // Значение по умолчанию для этого параметра равно 16. + MaxSyncQueueSize int + + // Service сервис для хранения записей + Service log.Service + + wg sync.WaitGroup + mu sync.RWMutex + buffer []*log.Entry + syncQueue chan []*log.Entry + + flushStop chan struct{} // flushStop закрывается, когда flushLoop должен быть остановлен + started bool // started указывает, был ли выполнен Start + stopped bool // stopped указывает, был ли выполнен Stop +} + +func (ws *BufferedWriteSyncer) start() { + if ws.Service == nil { + panic("service is required") + } + + if ws.FlushInterval == 0 { + ws.FlushInterval = defaultFlushInterval + } + + if ws.MaxBufferSize == 0 { + ws.MaxBufferSize = defaultMaxBufferSize + } + + if ws.MaxSyncQueueSize == 0 { + ws.MaxSyncQueueSize = defaultMaxSyncQueueSize + } + + ws.buffer = make([]*log.Entry, 0, ws.MaxBufferSize) + ws.syncQueue = make(chan []*log.Entry, ws.MaxSyncQueueSize) + ws.flushStop = make(chan struct{}) + + ws.wg.Add(2) + go ws.syncLoop() + go ws.flushLoop() + + ws.started = true +} + +func (ws *BufferedWriteSyncer) Stop() error { + ws.mu.Lock() + defer ws.mu.Unlock() + + if !ws.started || ws.stopped { + return nil + } + ws.stopped = true + + close(ws.flushStop) // завершаем flushLoop + + err := ws.flush() // очищаем оставшиеся записи + + close(ws.syncQueue) // завершаем syncLoop + + ws.wg.Wait() // дожидаемся завершения flushLoop и syncLoop + + return err +} + +// Write отправляет запись в буфер. +// Когда количество буферизованных записей превышает максимальный размер буфера, буферизированные записи будут отправлены на синхронизацию. +func (ws *BufferedWriteSyncer) Write(entry *log.Entry) error { + ws.mu.Lock() + defer ws.mu.Unlock() + + if !ws.started { + ws.start() + } + + // Проверяем, не достигли ли мы предела размера буфера. Если это так, тогда освобождаем его. + if len(ws.buffer)+1 > ws.MaxBufferSize { + err := ws.flush() + if err != nil { + return err + } + } + + ws.buffer = append(ws.buffer, entry) + + return nil +} + +// Sync освобождает буфер и отправляет буферизированные записи на синхронизацию. +func (ws *BufferedWriteSyncer) Sync() error { + ws.mu.Lock() + defer ws.mu.Unlock() + + if ws.started { + return ws.flush() + } + + return nil +} + +// flush освобождает буфер и отправляет буферизированные записи на синхронизацию. +// Если очередь на синхронизацию переполнена, будет возвращена ошибка SyncQueueOverflow +// +// ВНИМАНИЕ: Не является безопасным для конкурентного вызова. +func (ws *BufferedWriteSyncer) flush() error { + if len(ws.buffer) == 0 { + return nil + } + + // Проверяем, не достигли ли мы предела размера очереди. Если это так, возвращаем ошибку. + if len(ws.syncQueue)+1 > ws.MaxSyncQueueSize { + return SyncQueueOverflow + } + + ws.syncQueue <- ws.buffer + ws.buffer = make([]*log.Entry, 0, ws.MaxBufferSize) + + return nil +} + +// flushLoop периодически отправляет буферизированные записи на синхронизацию. +func (ws *BufferedWriteSyncer) flushLoop() { + ticker := time.NewTicker(ws.FlushInterval) + defer func() { + ticker.Stop() + ws.wg.Done() + }() + + for { + select { + case <-ticker.C: + _ = ws.Sync() + case <-ws.flushStop: + return + } + } +} + +// syncLoop синхронизирует записи с log.Service. +func (ws *BufferedWriteSyncer) syncLoop() { + defer ws.wg.Done() + + for entries := range ws.syncQueue { + _ = ws.Service.Log(context.Background(), entries) + } +} diff --git a/log/zap/buffered_write_syncer_test.go b/log/zap/buffered_write_syncer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..58e40098bb2370b7899d31fc42c2ea569ca81cf7 --- /dev/null +++ b/log/zap/buffered_write_syncer_test.go @@ -0,0 +1,143 @@ +package zap + +import ( + "sync" + "testing" + "time" + + "git.perx.ru/perxis/perxis-go/log" + logmocks "git.perx.ru/perxis/perxis-go/log/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestBufferedWriteSyncer_Write(t *testing.T) { + service := &logmocks.Service{} + service.On("Log", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + entries := args.Get(1).([]*log.Entry) + require.Equal(t, 2, len(entries)) + }). + Once() + + ws := &BufferedWriteSyncer{Service: service} + + err := ws.Write(&log.Entry{Message: "first log message"}) + require.NoError(t, err) + + err = ws.Write(&log.Entry{Message: "second log message"}) + require.NoError(t, err) + + err = ws.Stop() + require.NoError(t, err) + + service.AssertExpectations(t) +} + +func TestBufferedWriteSyncer_Write_Concurrent(t *testing.T) { + service := &logmocks.Service{} + service.On("Log", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + entries := args.Get(1).([]*log.Entry) + require.Equal(t, 100, len(entries)) + }). + Once() + + ws := &BufferedWriteSyncer{Service: service} + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(wg *sync.WaitGroup) { + defer wg.Done() + + err := ws.Write(&log.Entry{Message: "log message"}) + require.NoError(t, err) + }(&wg) + } + + wg.Wait() + + err := ws.Stop() + require.NoError(t, err) + + service.AssertExpectations(t) +} + +func TestBufferedWriteSyncer_Flush(t *testing.T) { + service := &logmocks.Service{} + service.On("Log", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + entries := args.Get(1).([]*log.Entry) + require.Equal(t, 10, len(entries)) + }). + Times(10) + + ws := &BufferedWriteSyncer{Service: service} + + for i := 0; i < 10; i++ { + for j := 0; j < 10; j++ { + err := ws.Write(&log.Entry{Message: "log message"}) + require.NoError(t, err) + } + err := ws.Sync() + require.NoError(t, err) + } + + err := ws.Stop() + require.NoError(t, err) + + service.AssertExpectations(t) +} + +func TestBufferedWriteSyncer_MaxBufferSize(t *testing.T) { + service := &logmocks.Service{} + service.On("Log", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + entries := args.Get(1).([]*log.Entry) + assert.Equal(t, 10, len(entries)) + }). + Times(10) + + ws := &BufferedWriteSyncer{Service: service, MaxBufferSize: 10} + + for i := 0; i < 100; i++ { + err := ws.Write(&log.Entry{Message: "log message"}) + require.NoError(t, err) + } + + err := ws.Stop() + require.NoError(t, err) + + service.AssertExpectations(t) +} + +func TestBufferedWriteSyncer_FlushInterval(t *testing.T) { + service := &logmocks.Service{} + service.On("Log", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + entries := args.Get(1).([]*log.Entry) + assert.Equal(t, 10, len(entries)) + }). + Once() + + ws := &BufferedWriteSyncer{Service: service, FlushInterval: time.Second} + + for j := 0; j < 10; j++ { + err := ws.Write(&log.Entry{Message: "log message"}) + require.NoError(t, err) + } + + time.Sleep(3 * time.Second) // ждем, пока сработает интервал + + err := ws.Stop() + require.NoError(t, err) + + service.AssertExpectations(t) +} diff --git a/log/zap/core.go b/log/zap/core.go new file mode 100644 index 0000000000000000000000000000000000000000..f2b220eab78a6cd2416408385b521c743bcefb94 --- /dev/null +++ b/log/zap/core.go @@ -0,0 +1,88 @@ +package zap + +import ( + oid "git.perx.ru/perxis/perxis-go/id" + "git.perx.ru/perxis/perxis-go/log" + "git.perx.ru/perxis/perxis-go/pkg/id" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// WriteSyncer отвечает за хранение и синхронизацию log.Entry +type WriteSyncer interface { + Write(entry *log.Entry) error + Sync() error +} + +// Core кодирует zapcore.Entry в log.Entry и отправляет их в WriteSyncer +type Core struct { + zapcore.LevelEnabler + + writeSyncer WriteSyncer + fields []zap.Field +} + +func NewCore(writeSyncer WriteSyncer) *Core { + return &Core{ + LevelEnabler: zapcore.InfoLevel, + writeSyncer: writeSyncer, + } +} + +func (core *Core) With(fields []zapcore.Field) zapcore.Core { + return &Core{ + LevelEnabler: core.LevelEnabler, + writeSyncer: core.writeSyncer, + fields: append(core.fields, fields...), + } +} + +func (core *Core) Check(entry zapcore.Entry, checkedEntry *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if core.Enabled(entry.Level) { + return checkedEntry.AddCore(entry, core) + } + return checkedEntry +} + +func (core *Core) Write(entry zapcore.Entry, fields []zapcore.Field) error { + return core.writeSyncer.Write(core.getEntry(entry, fields)) +} + +func (core *Core) Sync() error { + return core.writeSyncer.Sync() +} + +func (core *Core) getEntry(entry zapcore.Entry, fields []zapcore.Field) *log.Entry { + if len(core.fields) > 0 { + fields = append(fields, core.fields...) + } + + enc := zapcore.NewMapObjectEncoder() + for _, field := range fields { + field.AddTo(enc) + } + + ent := &log.Entry{ + ID: id.GenerateNewID(), + Timestamp: entry.Time, + LogLevel: log.Level(entry.Level), + Message: entry.Message, + } + + ent.Category, _ = enc.Fields["category"].(string) + ent.Component, _ = enc.Fields["component"].(string) + ent.Event, _ = enc.Fields["event"].(string) + ent.ObjectID, _ = enc.Fields["object"].(*oid.ObjectId) + ent.CallerID, _ = enc.Fields["caller"].(*oid.ObjectId) + ent.Attr = enc.Fields["attr"] + + if tags, ok := enc.Fields["tags"].([]any); ok { + for _, item := range tags { + if tag, ok := item.(string); ok { + ent.Tags = append(ent.Tags, tag) + } + } + } + + return ent +} diff --git a/log/zap/core_test.go b/log/zap/core_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f78916cb9faeb197179cebbaa608735d86ceddee --- /dev/null +++ b/log/zap/core_test.go @@ -0,0 +1,64 @@ +package zap + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/id" + "git.perx.ru/perxis/perxis-go/log" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestCore_getEntry(t *testing.T) { + core := NewCore(nil) + + tests := []struct { + name string + input struct { + entry zapcore.Entry + fields []zapcore.Field + } + want *log.Entry + }{ + { + name: "simple", + input: struct { + entry zapcore.Entry + fields []zapcore.Field + }{ + entry: zapcore.Entry{Level: zapcore.InfoLevel, Message: "создан элемент коллекции"}, + fields: []zapcore.Field{ + zap.String("key", "val"), // будет проигнорировано + Category("create"), + Component("Items.Service"), + Event("Items.Create"), + Object("/spaces/WPNN/envs/9VGP/cols/GxNv/items/W0fl"), + Caller("/users/PHVz"), + Attr("any"), + Tags("tag1", "tag2", "tag3"), + }, + }, + want: &log.Entry{ + LogLevel: log.Level(zapcore.InfoLevel), + Message: "создан элемент коллекции", + Category: "create", + Component: "Items.Service", + Event: "Items.Create", + ObjectID: id.MustObjectId("/spaces/WPNN/envs/9VGP/cols/GxNv/items/W0fl"), + CallerID: id.MustObjectId("/users/PHVz"), + Attr: "any", + Tags: []string{"tag1", "tag2", "tag3"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := core.getEntry(tc.input.entry, tc.input.fields) + got.ID = tc.want.ID // игнорируем ID + got.Timestamp = tc.want.Timestamp // игнорируем Timestamp + require.Equal(t, tc.want, got) + }) + } +} diff --git a/log/zap/example_test.go b/log/zap/example_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d99b83c23b4ae05eaa091d2c8e96e1820b90d6fb --- /dev/null +++ b/log/zap/example_test.go @@ -0,0 +1,96 @@ +package zap + +import ( + "context" + "reflect" + "slices" + "testing" + + "git.perx.ru/perxis/perxis-go/id" + "git.perx.ru/perxis/perxis-go/log" + logmocks "git.perx.ru/perxis/perxis-go/log/mocks" + "git.perx.ru/perxis/perxis-go/pkg/auth" + "git.perx.ru/perxis/perxis-go/pkg/items" + "git.perx.ru/perxis/perxis-go/pkg/users" + usersmocks "git.perx.ru/perxis/perxis-go/pkg/users/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestExample(t *testing.T) { + item := items.NewItem("WPNN", "9VGP", "GxNv", "W0fl", nil, nil) + user := &users.User{ID: "294de355"} + + wantEntries := []*log.Entry{ + { + LogLevel: log.Level(zapcore.InfoLevel), + Message: "Successfully created", + Component: "Items", + Event: items.EventCreateItem, + ObjectID: id.MustObjectId(item), + CallerID: id.MustObjectId(user), + Tags: []string{"tag1", "tag2", "tag3"}, + }, + { + LogLevel: log.Level(zapcore.WarnLevel), + Message: "Successfully updated", + Component: "Items", + Event: items.EventUpdateItem, + ObjectID: id.MustObjectId(item), + CallerID: id.MustObjectId(user), + Attr: map[string]map[string]any{"title": {"old": "old title", "new": "new title"}}, + }, + } + + service := &logmocks.Service{} + service.On("Log", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + entries := args.Get(1).([]*log.Entry) + require.True(t, slices.EqualFunc(wantEntries, entries, func(wantEntry, gotEntry *log.Entry) bool { + require.NotEmpty(t, gotEntry.ID) + require.NotEmpty(t, gotEntry.Timestamp) + gotEntry.ID = wantEntry.ID // игнорируем ID + gotEntry.Timestamp = wantEntry.Timestamp // игнорируем Timestamp + return reflect.DeepEqual(wantEntry, gotEntry) + })) + }). + Once() + + usersService := &usersmocks.Users{} + usersService.On("GetByIdentity", mock.Anything, "74d90aaf").Return(user, nil).Once() + + factory := auth.PrincipalFactory{Users: usersService} + + ws := &BufferedWriteSyncer{Service: service} + logger := zap.New(NewCore(ws)) + + // Пример отправки логов для сервиса Items + { + logger := logger.With(Component("Items")) + ctx := auth.WithPrincipal(context.Background(), factory.User("74d90aaf")) + + // Отправка лога при создании item + logger.Info("Successfully created", + Event(items.EventCreateItem), + Object(item), + CallerFromContext(ctx), + Tags("tag1", "tag2", "tag3"), + ) + + // Отправка лога при обновлении item + logger.Warn("Successfully updated", + Event(items.EventUpdateItem), + Object(item), + CallerFromContext(ctx), + Attr(map[string]map[string]any{"title": {"old": "old title", "new": "new title"}}), + ) + } + + err := ws.Stop() + require.NoError(t, err) + + service.AssertExpectations(t) +} diff --git a/log/zap/field.go b/log/zap/field.go index e0de88be61059c325821ff68afe0bcbc7b88b6ba..acc6932d5fab8b2975d09998557e6ed41197010d 100644 --- a/log/zap/field.go +++ b/log/zap/field.go @@ -2,78 +2,72 @@ package zap import ( "context" - "fmt" "git.perx.ru/perxis/perxis-go/id" + _ "git.perx.ru/perxis/perxis-go/id/system" // регистрируем обработчики для системных объектов "git.perx.ru/perxis/perxis-go/pkg/auth" "go.uber.org/zap" - "go.uber.org/zap/zapcore" ) -const ( - unknownObject = "unknown" - unknownCaller = "unknown" -) - -func Category(category string) zapcore.Field { +func Category(category string) zap.Field { + if category == "" { + return zap.Skip() + } return zap.String("category", category) } -func Component(component string) zapcore.Field { +func Component(component string) zap.Field { + if component == "" { + return zap.Skip() + } return zap.String("component", component) } -func Event(event string) zapcore.Field { +func Event(event string) zap.Field { + if event == "" { + return zap.Skip() + } return zap.String("event", event) } -// Object возвращает поле и устанавливает передаваемый аргумент в качестве идентификатора объекта в формате ObjectID. -// Поддерживаемые типы: string, fmt.Stringer. -// Если передан аргумент другого типа, будет произведена попытка привести переданное значение к ObjectID. -func Object(v any) zapcore.Field { - var object = unknownObject - switch value := v.(type) { - case string: - object = value - case fmt.Stringer: - object = value.String() - default: - oid, err := id.FromObject(v) - if err == nil { - object = oid.String() - } +// Object возвращает поле и устанавливает передаваемый аргумент в качестве идентификатора объекта в формате ObjectId. +// Поддерживает типы в формате ObjectId: id.Descriptor, string, map[string]any, системные объекты. +func Object(v any) zap.Field { + oid, err := id.NewObjectId(v) + if err != nil { + return zap.Skip() } - return zap.String("object", object) + return zap.Reflect("object", oid) } -// Caller возвращает поле и устанавливает передаваемый аргумент в качестве "вызывающего" в формате ObjectID. -// Поддерживаемые типы: string, fmt.Stringer. -// Если передан аргумент другого типа, будет произведена попытка привести переданное значение к ObjectID. -func Caller(v any) zapcore.Field { - var caller = unknownCaller - switch value := v.(type) { - case string: - caller = value - case fmt.Stringer: - caller = value.String() - default: - oid, err := id.FromObject(v) - if err == nil { - caller = oid.String() - } +// Caller возвращает поле и устанавливает передаваемый аргумент в качестве "вызывающего" в формате ObjectId. +// Поддерживает типы в формате ObjectId: id.Descriptor, string, map[string]any, системные объекты. +func Caller(v any) zap.Field { + oid, err := id.NewObjectId(v) + if err != nil { + return zap.Skip() } - return zap.String("caller", caller) + return zap.Reflect("caller", oid) } -// CallerFromContext извлекает auth.Principal из контекста и устанавливает его в качестве "вызывающего" в формате ObjectID. -func CallerFromContext(ctx context.Context) zapcore.Field { +// CallerFromContext извлекает auth.Principal из контекста и устанавливает его в качестве "вызывающего" в формате Object. +func CallerFromContext(ctx context.Context) zap.Field { + if ctx == nil { + return zap.Skip() + } return Caller(auth.GetPrincipal(ctx)) } -func Attr(attr any) zapcore.Field { +func Attr(attr any) zap.Field { + if attr == nil { + return zap.Skip() + } return zap.Any("attr", attr) } -func Tags(tags ...string) zapcore.Field { +func Tags(tags ...string) zap.Field { + if len(tags) == 0 { + return zap.Skip() + } return zap.Strings("tags", tags) } diff --git a/log/zap/field_test.go b/log/zap/field_test.go new file mode 100644 index 0000000000000000000000000000000000000000..643506bac023d51b25920ae6b5a3c84d511eb342 --- /dev/null +++ b/log/zap/field_test.go @@ -0,0 +1,185 @@ +package zap + +import ( + "context" + "testing" + + "git.perx.ru/perxis/perxis-go/id" + "git.perx.ru/perxis/perxis-go/pkg/auth" + "git.perx.ru/perxis/perxis-go/pkg/items" + "git.perx.ru/perxis/perxis-go/pkg/users" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestCategory(t *testing.T) { + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "ok", field: Category("update"), want: zap.String("category", "update")}, + {name: "invalid", field: Category(""), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, tc.want.Equals(tc.field)) + }) + } +} + +func TestComponent(t *testing.T) { + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "ok", field: Component("Items"), want: zap.String("component", "Items")}, + {name: "invalid", field: Component(""), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, tc.want.Equals(tc.field)) + }) + } +} + +func TestEvent(t *testing.T) { + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "ok", field: Event("items.create"), want: zap.String("event", "items.create")}, + {name: "invalid", field: Event(""), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, tc.want.Equals(tc.field)) + }) + } +} + +func TestObjectID(t *testing.T) { + item := &items.Item{ + ID: "c4ca4238a0b923820dcc509a6f75849b", + SpaceID: "c81e728d9d4c2f636f067f89cc14862c", + EnvID: "eccbc87e4b5ce2fe28308fd9f2a7baf3", + CollectionID: "a87ff679a2f3e71d9181a67b7542122c", + } + + oid := id.MustObjectId(item) + itemId := id.NewItemId(*item) + + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "system object", field: Object(item), want: zap.Reflect("object", oid)}, + {name: "object id", field: Object(itemId), want: zap.Reflect("object", oid)}, + {name: "string", field: Object(oid.String()), want: zap.Reflect("object", oid)}, + {name: "invalid", field: Object(nil), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.want.Equals(zap.Skip()) { + assert.True(t, tc.want.Equals(tc.field)) + return + } + assert.Equal(t, tc.want.Interface.(id.Descriptor).String(), tc.field.Interface.(id.Descriptor).String()) + }) + } +} + +func TestCallerID(t *testing.T) { + user := &users.User{ + ID: "c4ca4238a0b923820dcc509a6f75849b", + } + + oid := id.MustObjectId(user) + userId := id.NewUserId(*user) + + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "system object", field: Caller(user), want: zap.Reflect("caller", oid)}, + {name: "object id", field: Caller(userId), want: zap.Reflect("caller", oid)}, + {name: "string", field: Caller(oid.String()), want: zap.Reflect("caller", oid)}, + {name: "invalid", field: Caller(nil), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.want.Equals(zap.Skip()) { + assert.True(t, tc.want.Equals(tc.field)) + return + } + assert.Equal(t, tc.want.Interface.(id.Descriptor).String(), tc.field.Interface.(id.Descriptor).String()) + }) + } +} + +func TestCallerIDFromContext(t *testing.T) { + ctx := auth.WithSystem(context.Background()) + oid := id.MustObjectId(auth.GetPrincipal(ctx)) + + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "ok", field: CallerFromContext(ctx), want: zap.Reflect("caller", oid)}, + {name: "invalid", field: CallerFromContext(nil), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.want.Equals(zap.Skip()) { + assert.True(t, tc.want.Equals(tc.field)) + return + } + assert.Equal(t, tc.want.Interface.(id.Descriptor).String(), tc.field.Interface.(id.Descriptor).String()) + }) + } +} + +func TestAttr(t *testing.T) { + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "ok", field: Attr(map[string]string{"a": "b"}), want: zap.Reflect("attr", map[string]string{"a": "b"})}, + {name: "invalid", field: Attr(nil), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, tc.want.Equals(tc.field)) + }) + } +} + +func TestTags(t *testing.T) { + tests := []struct { + name string + field zap.Field + want zap.Field + }{ + {name: "ok", field: Tags("a", "b", "c"), want: zap.Strings("tags", []string{"a", "b", "c"})}, + {name: "invalid", field: Tags(nil...), want: zap.Skip()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, tc.want.Equals(tc.field)) + }) + } +}