diff --git a/core/healthcheck/counter_test.go b/core/healthcheck/counter_test.go new file mode 100644 index 0000000..06f51d5 --- /dev/null +++ b/core/healthcheck/counter_test.go @@ -0,0 +1,122 @@ +package healthcheck + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type AtomicCounterTest struct { + suite.Suite +} + +func TestAtomicCounter(t *testing.T) { + suite.Run(t, new(AtomicCounterTest)) +} + +func (t *AtomicCounterTest) new() Counter { + return NewAtomicCounter("test") +} + +func (t *AtomicCounterTest) Test_Name() { + t.Assert().Equal("test", t.new().Name()) +} + +func (t *AtomicCounterTest) Test_SetName() { + c := t.new() + c.SetName("new") + t.Assert().Equal("new", c.Name()) +} + +func (t *AtomicCounterTest) Test_HitSuccess() { + c := t.new() + c.HitSuccess() + t.Assert().Equal(uint32(1), c.TotalSucceeded()) + + c.Failed("test") + c.FailureProcessed() + c.HitSuccess() + t.Assert().Equal(uint32(2), c.TotalSucceeded()) + t.Assert().False(c.IsFailed()) + t.Assert().False(c.IsFailureProcessed()) + t.Assert().Equal("", c.Message()) +} + +func (t *AtomicCounterTest) Test_HitFailure() { + c := t.new() + c.HitFailure() + t.Assert().Equal(uint32(1), c.TotalFailed()) + c.HitFailure() + t.Assert().Equal(uint32(2), c.TotalFailed()) +} + +func (t *AtomicCounterTest) Test_Failed() { + c := t.new() + t.Require().False(c.IsFailed()) + t.Require().Equal("", c.Message()) + + c.Failed("message") + t.Assert().True(c.IsFailed()) + t.Assert().Equal("message", c.Message()) +} + +func (t *AtomicCounterTest) Test_CountersProcessed() { + c := t.new() + t.Require().False(c.IsCountersProcessed()) + + c.CountersProcessed() + t.Assert().True(c.IsCountersProcessed()) + + c.ClearCountersProcessed() + t.Assert().False(c.IsCountersProcessed()) +} + +func (t *AtomicCounterTest) Test_FlushCounters() { + c := t.new() + c.HitSuccess() + t.Require().Equal(uint32(1), c.TotalSucceeded()) + + c.FlushCounters() + t.Assert().Equal(uint32(1), c.TotalSucceeded()) + + c.(*AtomicCounter).timestamp.Store(time.Now().Add(-(DefaultResetPeriod + time.Second))) + c.FlushCounters() + t.Assert().Equal(uint32(0), c.TotalSucceeded()) +} + +func (t *AtomicCounterTest) Test_Concurrency() { + c := t.new() + var wg sync.WaitGroup + wg.Add(2) + go func() { + for i := 0; i < 1000; i++ { + c.HitSuccess() + } + wg.Done() + }() + go func() { + for i := 0; i < 500; i++ { + // this delay will ensure that failure is being called after success. + // technically, both have been executed concurrently because first 399 calls will not be delayed. + if i > 399 { + time.Sleep(time.Microsecond) + } + if i > 400 { + c.Failed("total failure") + continue + } + c.HitFailure() + } + c.FailureProcessed() + wg.Done() + }() + wg.Wait() + + t.Assert().Equal(uint32(1000), c.TotalSucceeded()) + t.Assert().Equal(uint32(401), c.TotalFailed()) + t.Assert().True(c.IsFailed()) + t.Assert().True(c.IsFailureProcessed()) + t.Assert().Equal("total failure", c.Message()) +} diff --git a/core/healthcheck/notifier_test.go b/core/healthcheck/notifier_test.go new file mode 100644 index 0000000..bb3aee1 --- /dev/null +++ b/core/healthcheck/notifier_test.go @@ -0,0 +1,65 @@ +package healthcheck + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + retailcrm "github.com/retailcrm/api-client-go/v2" + "github.com/retailcrm/mg-transport-core/v2/core/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/h2non/gock.v1" +) + +func TestDefaultNotifyFunc(t *testing.T) { + apiURL := "https://test.retailcrm.pro" + apiKey := "key" + msg := "Notification" + + data, err := json.Marshal(retailcrm.NotificationsSendRequest{ + UserGroups: []retailcrm.UserGroupType{retailcrm.UserGroupSuperadmins}, + Type: retailcrm.NotificationTypeError, + Message: msg, + }) + require.NoError(t, err) + + defer gock.Off() + gock.New(apiURL). + Post("/api/v5/notifications/send"). + BodyString(url.Values{"notification": {string(data)}}.Encode()). + Reply(http.StatusOK). + JSON(retailcrm.SuccessfulResponse{Success: true}) + + assert.NoError(t, DefaultNotifyFunc(apiURL, apiKey, msg)) + testutil.AssertNoUnmatchedRequests(t) +} + +func TestDefaultNotifyFunc_Error(t *testing.T) { + apiURL := "https://test.retailcrm.pro" + apiKey := "key" + msg := "Notification" + + data, err := json.Marshal(retailcrm.NotificationsSendRequest{ + UserGroups: []retailcrm.UserGroupType{retailcrm.UserGroupSuperadmins}, + Type: retailcrm.NotificationTypeError, + Message: msg, + }) + require.NoError(t, err) + + defer gock.Off() + gock.New(apiURL). + Post("/api/v5/notifications/send"). + BodyString(url.Values{"notification": {string(data)}}.Encode()). + Reply(http.StatusForbidden). + JSON(retailcrm.ErrorResponse{ + SuccessfulResponse: retailcrm.SuccessfulResponse{Success: false}, + ErrorMessage: "Forbidden", + }) + + err = DefaultNotifyFunc(apiURL, apiKey, msg) + assert.Error(t, err) + assert.Equal(t, "Forbidden", err.Error()) + testutil.AssertNoUnmatchedRequests(t) +} diff --git a/core/healthcheck/processor.go b/core/healthcheck/processor.go index ddfb13e..4e213cc 100644 --- a/core/healthcheck/processor.go +++ b/core/healthcheck/processor.go @@ -1,13 +1,9 @@ package healthcheck import ( - "errors" - "github.com/retailcrm/mg-transport-core/v2/core/logger" ) -var ErrNoConnection = errors.New("no connection") - const ( // DefaultMinRequests is a default minimal threshold of total requests. If Counter has less than this amount of requests // total, it will be skipped because it can trigger false alerts otherwise. diff --git a/core/healthcheck/processor_test.go b/core/healthcheck/processor_test.go new file mode 100644 index 0000000..c6d30a9 --- /dev/null +++ b/core/healthcheck/processor_test.go @@ -0,0 +1,363 @@ +package healthcheck + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/retailcrm/mg-transport-core/v2/core/util/testutil" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type CounterProcessorTest struct { + suite.Suite + apiURL string + apiKey string + lang string +} + +func TestCounterProcessor(t *testing.T) { + suite.Run(t, new(CounterProcessorTest)) +} + +func (t *CounterProcessorTest) SetupSuite() { + t.apiURL = "https://test.retailcrm.pro" + t.apiKey = "key" + t.lang = "en" +} + +func (t *CounterProcessorTest) localizer() NotifyMessageLocalizer { + loc := &localizerMock{} + loc.On("SetLocale", mock.AnythingOfType("string")).Return() + loc.On("GetLocalizedTemplateMessage", + mock.AnythingOfType("string"), mock.Anything).Return( + func(msg string, tpl map[string]interface{}) string { + data, err := json.Marshal(tpl) + if err != nil { + panic(err) + } + return fmt.Sprintf("%s [%s]", msg, string(data)) + }) + return loc +} + +func (t *CounterProcessorTest) new( + nf NotifyFunc, pr ConnectionDataProvider, noLocalizer ...bool) (Processor, testutil.BufferedLogger) { + loc := t.localizer() + if len(noLocalizer) > 0 && noLocalizer[0] { + loc = nil + } + + log := testutil.NewBufferedLogger() + return CounterProcessor{ + Localizer: loc, + Logger: log, + Notifier: nf, + ConnectionDataProvider: pr, + Error: "default error", + FailureThreshold: DefaultFailureThreshold, + MinRequests: DefaultMinRequests, + Debug: true, + }, log +} + +func (t *CounterProcessorTest) notifier(err ...error) *notifierMock { + if len(err) > 0 && err[0] != nil { + return ¬ifierMock{err: err[0]} + } + return ¬ifierMock{} +} + +func (t *CounterProcessorTest) provider(notFound ...bool) ConnectionDataProvider { + if len(notFound) > 0 && notFound[0] { + return func(id int) (apiURL, apiKey, lang string, exists bool) { + return "", "", "", false + } + } + return func(id int) (apiURL, apiKey, lang string, exists bool) { + return t.apiURL, t.apiKey, t.lang, true + } +} + +func (t *CounterProcessorTest) counter() mockedCounter { + return &counterMock{} +} + +func (t *CounterProcessorTest) Test_FailureProcessed() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(true) + c.On("IsFailureProcessed").Return(true) + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Contains(log.String(), "skipping counter id=1 because its failure is already processed") +} + +func (t *CounterProcessorTest) Test_CounterFailed_CannotFindConnection() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider(true)) + c := t.counter() + c.On("IsFailed").Return(true) + c.On("IsFailureProcessed").Return(false) + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Contains(log.String(), "cannot find connection data for counter id=1") +} + +func (t *CounterProcessorTest) Test_CounterFailed_ErrWhileNotifying() { + n := t.notifier(errors.New("http status code: 500")) + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(true) + c.On("IsFailureProcessed").Return(false) + c.On("Message").Return("error message") + c.On("FailureProcessed").Return() + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Contains(log.String(), "cannot send notification for counter id=1: http status code: 500 (message: error message)") + t.Assert().Equal(t.apiURL, n.apiURL) + t.Assert().Equal(t.apiKey, n.apiKey) + t.Assert().Equal("error message", n.message) +} + +func (t *CounterProcessorTest) Test_CounterFailed_SentNotification() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(true) + c.On("IsFailureProcessed").Return(false) + c.On("Message").Return("error message") + c.On("FailureProcessed").Return() + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Empty(log.String()) + t.Assert().Equal(t.apiURL, n.apiURL) + t.Assert().Equal(t.apiKey, n.apiKey) + t.Assert().Equal("error message", n.message) +} + +func (t *CounterProcessorTest) Test_TooFewRequests() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(0)) + c.On("TotalSucceeded").Return(uint32(DefaultMinRequests - 1)) + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Contains(log.String(), + fmt.Sprintf("skipping counter id=%d because it has fewer than %d requests", 1, DefaultMinRequests)) +} + +func (t *CounterProcessorTest) Test_ThresholdNotPassed() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(20)) + c.On("TotalSucceeded").Return(uint32(80)) + c.On("ClearCountersProcessed").Return() + c.On("FlushCounters").Return() + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Empty(log.String()) + t.Assert().Empty(n.message) +} + +func (t *CounterProcessorTest) Test_ThresholdPassed_AlreadyProcessed() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(21)) + c.On("TotalSucceeded").Return(uint32(79)) + c.On("IsCountersProcessed").Return(true) + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Empty(log.String()) + t.Assert().Empty(n.message) +} + +func (t *CounterProcessorTest) Test_ThresholdPassed_NoConnectionFound() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider(true)) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(21)) + c.On("TotalSucceeded").Return(uint32(79)) + c.On("IsCountersProcessed").Return(false) + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Contains(log.String(), "cannot find connection data for counter id=1") + t.Assert().Empty(n.message) +} + +func (t *CounterProcessorTest) Test_ThresholdPassed_NotifyingError() { + n := t.notifier(errors.New("unknown error")) + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(21)) + c.On("TotalSucceeded").Return(uint32(79)) + c.On("IsCountersProcessed").Return(false) + c.On("Name").Return("MockedCounter") + c.On("Message").Return("") + c.On("CountersProcessed").Return() + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Contains(log.String(), "cannot send notification for counter id=1: unknown error (message: )") + t.Assert().Equal(`default error [{"Name":"MockedCounter"}]`, n.message) +} + +func (t *CounterProcessorTest) Test_ThresholdPassed_NotificationSent() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider()) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(21)) + c.On("TotalSucceeded").Return(uint32(79)) + c.On("IsCountersProcessed").Return(false) + c.On("Name").Return("MockedCounter") + c.On("CountersProcessed").Return() + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Empty(log.String()) + t.Assert().Equal(`default error [{"Name":"MockedCounter"}]`, n.message) +} + +func (t *CounterProcessorTest) Test_ThresholdPassed_NotificationSent_NoLocalizer() { + n := t.notifier() + p, log := t.new(n.Notify, t.provider(), true) + c := t.counter() + c.On("IsFailed").Return(false) + c.On("TotalFailed").Return(uint32(21)) + c.On("TotalSucceeded").Return(uint32(79)) + c.On("IsCountersProcessed").Return(false) + c.On("Name").Return("MockedCounter") + c.On("CountersProcessed").Return() + + p.Process(1, c) + c.AssertExpectations(t.T()) + t.Assert().Empty(log.String()) + t.Assert().Equal(`default error`, n.message) +} + +type localizerMock struct { + mock.Mock +} + +func (l *localizerMock) SetLocale(lang string) { + l.Called(lang) +} + +func (l *localizerMock) GetLocalizedTemplateMessage(messageID string, templateData map[string]interface{}) string { + args := l.Called(messageID, templateData) + if fn, ok := args.Get(0).(func(string, map[string]interface{}) string); ok { + return fn(messageID, templateData) + } + return args.String(0) +} + +type mockedCounter interface { + Counter + On(methodName string, arguments ...interface{}) *mock.Call + AssertExpectations(t mock.TestingT) bool +} + +type counterMock struct { + mock.Mock +} + +func (cm *counterMock) Name() string { + args := cm.Called() + return args.String(0) +} + +func (cm *counterMock) SetName(name string) { + cm.Called(name) +} + +func (cm *counterMock) HitSuccess() { + cm.Called() +} + +func (cm *counterMock) HitFailure() { + cm.Called() +} + +func (cm *counterMock) TotalSucceeded() uint32 { + args := cm.Called() + return args.Get(0).(uint32) +} + +func (cm *counterMock) TotalFailed() uint32 { + args := cm.Called() + return args.Get(0).(uint32) +} + +func (cm *counterMock) Message() string { + args := cm.Called() + return args.String(0) +} + +func (cm *counterMock) IsFailed() bool { + args := cm.Called() + return args.Bool(0) +} + +func (cm *counterMock) Failed(message string) { + cm.Called(message) +} + +func (cm *counterMock) IsFailureProcessed() bool { + args := cm.Called() + return args.Bool(0) +} + +func (cm *counterMock) IsCountersProcessed() bool { + args := cm.Called() + return args.Bool(0) +} + +func (cm *counterMock) FailureProcessed() { + cm.Called() +} + +func (cm *counterMock) CountersProcessed() { + cm.Called() +} + +func (cm *counterMock) ClearCountersProcessed() { + cm.Called() +} + +func (cm *counterMock) FlushCounters() { + cm.Called() +} + +type notifierMock struct { + apiURL string + apiKey string + message string + err error +} + +func (n *notifierMock) Notify(apiURL, apiKey, msg string) error { + n.apiURL = apiURL + n.apiKey = apiKey + n.message = msg + return n.err +} diff --git a/core/healthcheck/storage_test.go b/core/healthcheck/storage_test.go new file mode 100644 index 0000000..ce4919d --- /dev/null +++ b/core/healthcheck/storage_test.go @@ -0,0 +1,68 @@ +package healthcheck + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/suite" +) + +type SyncMapStorageTest struct { + suite.Suite + storage Storage +} + +func TestSyncMapStorage(t *testing.T) { + suite.Run(t, new(SyncMapStorageTest)) +} + +func (t *SyncMapStorageTest) SetupSuite() { + t.storage = NewSyncMapStorage(NewAtomicCounter) +} + +func (t *SyncMapStorageTest) Test_Get() { + counter := t.storage.Get(1, "Name") + t.Assert().NotNil(counter) + t.Assert().IsType(&AtomicCounter{}, counter) + t.Assert().Equal("Name", counter.Name()) + + newCounter := t.storage.Get(1, "New Name") + t.Assert().Equal(counter, newCounter) + t.Assert().Equal("New Name", newCounter.Name()) +} + +func (t *SyncMapStorageTest) Test_Process() { + var wg sync.WaitGroup + wg.Add(1) + t.storage.Process(storageCallbackProcessor{callback: func(id int, counter Counter) bool { + t.Assert().Equal(1, id) + t.Assert().Equal("New Name", counter.Name()) + wg.Done() + return false + }}) + + wg.Wait() +} + +func (t *SyncMapStorageTest) Test_Remove() { + defer func() { + if r := recover(); r != nil { + t.Fail("unexpected panic:", r) + } + }() + t.storage.Remove(0) + t.storage.Remove(-1) + t.storage.Remove(1) + t.storage.Process(storageCallbackProcessor{callback: func(id int, counter Counter) bool { + t.Fail("did not expect any items:", id, counter) + return false + }}) +} + +type storageCallbackProcessor struct { + callback func(id int, counter Counter) bool +} + +func (p storageCallbackProcessor) Process(id int, counter Counter) bool { + return p.callback(id, counter) +} diff --git a/go.mod b/go.mod index 5303606..d8afc1b 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,7 @@ require ( github.com/modern-go/reflect2 v1.0.1 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/ugorji/go/codec v1.2.6 // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect diff --git a/go.sum b/go.sum index a04db1a..6d8dfcd 100644 --- a/go.sum +++ b/go.sum @@ -385,6 +385,7 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=