Completely rework mojang textures queue implementation, split it across separate data providers

This commit is contained in:
ErickSkrauch
2019-11-21 01:33:13 +03:00
parent 6fd88e077e
commit 0644dfe021
34 changed files with 1405 additions and 1180 deletions

View File

@@ -0,0 +1,133 @@
package mojangtextures
import (
"strings"
"sync"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
type jobResult struct {
profile *mojang.ProfileInfo
error error
}
type jobItem struct {
username string
respondChan chan *jobResult
}
type jobsQueue struct {
lock sync.Mutex
items []*jobItem
}
func (s *jobsQueue) New() *jobsQueue {
s.items = []*jobItem{}
return s
}
func (s *jobsQueue) Enqueue(t *jobItem) {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, t)
}
func (s *jobsQueue) Dequeue(n int) []*jobItem {
s.lock.Lock()
defer s.lock.Unlock()
if n > s.Size() {
n = s.Size()
}
items := s.items[0:n]
s.items = s.items[n:len(s.items)]
return items
}
func (s *jobsQueue) Size() int {
return len(s.items)
}
var usernamesToUuids = mojang.UsernamesToUuids
var forever = func() bool {
return true
}
type BatchUuidsProvider struct {
IterationDelay time.Duration
IterationSize int
Logger wd.Watchdog
onFirstCall sync.Once
queue jobsQueue
}
func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
ctx.onFirstCall.Do(func() {
ctx.queue.New()
ctx.startQueue()
})
resultChan := make(chan *jobResult)
ctx.queue.Enqueue(&jobItem{username, resultChan})
ctx.Logger.IncCounter("mojang_textures.usernames.queued", 1)
result := <-resultChan
return result.profile, result.error
}
func (ctx *BatchUuidsProvider) startQueue() {
go func() {
time.Sleep(ctx.IterationDelay)
for forever() {
start := time.Now()
ctx.queueRound()
elapsed := time.Since(start)
ctx.Logger.RecordTimer("mojang_textures.usernames.round_time", elapsed)
time.Sleep(ctx.IterationDelay)
}
}()
}
func (ctx *BatchUuidsProvider) queueRound() {
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(ctx.IterationSize)
ctx.Logger.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize-len(jobs)))
ctx.Logger.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(jobs)))
if len(jobs) == 0 {
return
}
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.username)
}
profiles, err := usernamesToUuids(usernames)
for _, job := range jobs {
go func(job *jobItem) {
response := &jobResult{}
if err != nil {
response.error = err
} else {
// The profiles in the response aren't ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.username, profile.Name) {
response.profile = profile
break
}
}
}
job.respondChan <- response
}(job)
}
}

View File

@@ -0,0 +1,285 @@
package mojangtextures
import (
"crypto/rand"
"encoding/base64"
"strings"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
mocks "github.com/elyby/chrly/tests"
)
func TestJobsQueue(t *testing.T) {
createQueue := func() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}
t.Run("Enqueue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
assert.Equal(3, s.Size())
})
t.Run("Dequeue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
s.Enqueue(&jobItem{username: "username4"})
items := s.Dequeue(2)
assert.Len(items, 2)
assert.Equal("username1", items[0].username)
assert.Equal("username2", items[1].username)
assert.Equal(2, s.Size())
items = s.Dequeue(40)
assert.Len(items, 2)
assert.Equal("username3", items[0].username)
assert.Equal("username4", items[1].username)
})
}
// This is really stupid test just to get 100% coverage on this package :)
func TestBatchUuidsProvider_forever(t *testing.T) {
testify.True(t, forever())
}
type mojangUsernamesToUuidsRequestMock struct {
mock.Mock
}
func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(usernames []string) ([]*mojang.ProfileInfo, error) {
args := o.Called(usernames)
var result []*mojang.ProfileInfo
if casted, ok := args.Get(0).([]*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type batchUuidsProviderGetUuidResult struct {
Result *mojang.ProfileInfo
Error error
}
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
GetUuidAsync func(username string) chan *batchUuidsProviderGetUuidResult
Logger *mocks.WdMock
MojangApi *mojangUsernamesToUuidsRequestMock
Iterate func()
done func()
iterateChan chan bool
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Logger = &mocks.WdMock{}
suite.Provider = &BatchUuidsProvider{
Logger: suite.Logger,
IterationDelay: 0,
IterationSize: 10,
}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.GetUuidAsync = func(username string) chan *batchUuidsProviderGetUuidResult {
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
return c
}
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
usernamesToUuids = suite.MojangApi.UsernamesToUuids
}
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.done()
time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls
suite.MojangApi.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForOneUsername() {
expectedResult := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{expectedResult}, nil)
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result.Result)
suite.Assert().Nil(result.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Twice()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"username1", "username2"}).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
expectedResult2,
}, nil)
resultChan1 := suite.GetUuidAsync("username1")
time.Sleep(time.Millisecond) // Just to keep order for the usernames
resultChan2 := suite.GetUuidAsync("username2")
time.Sleep(time.Millisecond) // Allow to all goroutines begin
suite.Iterate()
result1 := <-resultChan1
suite.Assert().Equal(expectedResult1, result1.Result)
suite.Assert().Nil(result1.Error)
result2 := <-resultChan2
suite.Assert().Equal(expectedResult2, result2.Result)
suite.Assert().Nil(result2.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Times(12)
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(10)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Twice()
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return([]*mojang.ProfileInfo{}, nil)
channels := make([]chan *batchUuidsProviderGetUuidResult, 12)
for i, username := range usernames {
channels[i] = suite.GetUuidAsync(username)
time.Sleep(time.Millisecond) // Just to keep order for the usernames
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
}
func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(0)).Twice()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Times(3)
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finish
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Nil(result.Result)
suite.Assert().Nil(result.Error)
// Let it to perform a few more iterations to ensure, that there is no calls to external APIs
suite.Iterate()
suite.Iterate()
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError() {
expectedError := &mojang.TooManyRequestsError{}
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Twice()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"username1", "username2"}).Once().Return(nil, expectedError)
resultChan1 := suite.GetUuidAsync("username1")
time.Sleep(time.Millisecond) // Just to keep order for the usernames
resultChan2 := suite.GetUuidAsync("username2")
time.Sleep(time.Millisecond) // Allow to all goroutines begin
suite.Iterate()
result1 := <-resultChan1
suite.Assert().Nil(result1.Result)
suite.Assert().Equal(expectedError, result1.Error)
result2 := <-resultChan2
suite.Assert().Nil(result2.Result)
suite.Assert().Equal(expectedError, result2.Error)
}
var replacer = strings.NewReplacer("-", "_", "=", "")
// https://stackoverflow.com/a/50581165
func randStr(len int) string {
buff := make([]byte, len)
_, _ = rand.Read(buff)
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
// Base 64 can be longer than len
return str[:len]
}

View File

@@ -0,0 +1,115 @@
package mojangtextures
import (
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/tevino/abool"
)
var now = time.Now
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
}
type InMemoryTexturesStorage struct {
GCPeriod time.Duration
Duration time.Duration
lock sync.Mutex
data map[string]*inMemoryItem
working *abool.AtomicBool
}
func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
storage := &InMemoryTexturesStorage{
GCPeriod: 10 * time.Second,
Duration: time.Minute + 10*time.Second,
data: make(map[string]*inMemoryItem),
}
return storage
}
func (s *InMemoryTexturesStorage) Start() {
if s.working == nil {
s.working = abool.New()
}
if !s.working.IsSet() {
go func() {
time.Sleep(s.GCPeriod)
// TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right
for s.working.IsSet() {
start := time.Now()
s.gc()
time.Sleep(s.GCPeriod - time.Since(start))
}
}()
}
s.working.Set()
}
func (s *InMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.Lock()
defer s.lock.Unlock()
item, exists := s.data[uuid]
validRange := s.getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, &ValueNotFound{}
}
return item.textures, nil
}
func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
var timestamp int64
if textures != nil {
decoded := textures.DecodeTextures()
if decoded == nil {
panic("unable to decode textures")
}
timestamp = decoded.Timestamp
} else {
timestamp = unixNanoToUnixMicro(now().UnixNano())
}
s.lock.Lock()
defer s.lock.Unlock()
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: timestamp,
}
}
func (s *InMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
maxTime := s.getMinimalNotExpiredTimestamp()
for uuid, value := range s.data {
if maxTime > value.timestamp {
delete(s.data, uuid)
}
}
}
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(s.Duration * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
}

View File

@@ -0,0 +1,200 @@
package mojangtextures
import (
"time"
"github.com/elyby/chrly/api/mojang"
testify "github.com/stretchr/testify/assert"
"testing"
)
var texturesWithSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{
Skin: &mojang.SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
},
},
}),
},
},
}
var texturesWithoutSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get error when uuid is not exists", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
})
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
now = func() time.Time {
return time.Now().Add(time.Minute * 2)
}
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
now = time.Now
})
}
func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("override already existed textures for uuid", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(texturesWithoutSkin, result)
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Nil(err)
})
t.Run("should panic if textures prop is not decoded", func(t *testing.T) {
assert := testify.New(t)
toStore := &mojang.SignedTexturesResponse{
Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
Name: "mock",
Props: []*mojang.Property{},
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.GCPeriod = 10 * time.Millisecond
storage.Duration = 10 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
time.Sleep(storage.GCPeriod + time.Millisecond) // Let it start first iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(storage.GCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Error(textures1Err)
assert.Error(textures2Err)
storage.Stop()
}

View File

@@ -0,0 +1,25 @@
package mojangtextures
import (
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
var uuidToTextures = mojang.UuidToTextures
type MojangApiTexturesProvider struct {
Logger wd.Watchdog
}
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Logger.IncCounter("mojang_textures.textures.request", 1)
start := time.Now()
result, err := uuidToTextures(uuid, true)
ctx.Logger.RecordTimer("mojang_textures.textures.request_time", time.Since(start))
return result, err
}

View File

@@ -0,0 +1,82 @@
package mojangtextures
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
mocks "github.com/elyby/chrly/tests"
)
type mojangUuidToTexturesRequestMock struct {
mock.Mock
}
func (o *mojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) {
args := o.Called(uuid, signed)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type mojangApiTexturesProviderTestSuite struct {
suite.Suite
Provider *MojangApiTexturesProvider
Logger *mocks.WdMock
MojangApi *mojangUuidToTexturesRequestMock
}
func (suite *mojangApiTexturesProviderTestSuite) SetupTest() {
suite.Logger = &mocks.WdMock{}
suite.MojangApi = &mojangUuidToTexturesRequestMock{}
suite.Provider = &MojangApiTexturesProvider{
Logger: suite.Logger,
}
uuidToTextures = suite.MojangApi.UuidToTextures
}
func (suite *mojangApiTexturesProviderTestSuite) TearDownTest() {
suite.MojangApi.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func TestMojangApiTexturesProvider(t *testing.T) {
suite.Run(t, new(mojangApiTexturesProviderTestSuite))
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
expectedResult := &mojang.SignedTexturesResponse{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(expectedResult, nil)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Equal(expectedResult, result)
suite.Assert().Nil(err)
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
expectedError := &mojang.TooManyRequestsError{}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedError, err)
}

View File

@@ -0,0 +1,225 @@
package mojangtextures
import (
"errors"
"net"
"net/url"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
type broadcastResult struct {
textures *mojang.SignedTexturesResponse
error error
}
type broadcaster struct {
lock sync.Mutex
listeners map[string][]chan *broadcastResult
}
func createBroadcaster() *broadcaster {
return &broadcaster{
listeners: make(map[string][]chan *broadcastResult),
}
}
// Returns a boolean value, which will be true if the passed username didn't exist before
func (c *broadcaster) AddListener(username string, resultChan chan *broadcastResult) bool {
c.lock.Lock()
defer c.lock.Unlock()
val, alreadyHasSource := c.listeners[username]
if alreadyHasSource {
c.listeners[username] = append(val, resultChan)
return false
}
c.listeners[username] = []chan *broadcastResult{resultChan}
return true
}
func (c *broadcaster) BroadcastAndRemove(username string, result *broadcastResult) {
c.lock.Lock()
defer c.lock.Unlock()
val, ok := c.listeners[username]
if !ok {
return
}
for _, channel := range val {
go func(channel chan *broadcastResult) {
channel <- result
close(channel)
}(channel)
}
delete(c.listeners, username)
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
type UuidsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type TexturesProvider interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
}
type Provider struct {
UuidsProvider
TexturesProvider
Storage
Logger wd.Watchdog
onFirstCall sync.Once
*broadcaster
}
func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
ctx.onFirstCall.Do(func() {
ctx.broadcaster = createBroadcaster()
})
if !allowedUsernamesRegex.MatchString(username) {
ctx.Logger.IncCounter("mojang_textures.invalid_username", 1)
return nil, errors.New("invalid username")
}
username = strings.ToLower(username)
ctx.Logger.IncCounter("mojang_textures.request", 1)
uuid, err := ctx.Storage.GetUuid(username)
if err == nil && uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
return nil, nil
}
if uuid != "" {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit", 1)
textures, err := ctx.Storage.GetTextures(uuid)
if err == nil {
ctx.Logger.IncCounter("mojang_textures.textures.cache_hit", 1)
return textures, nil
}
}
resultChan := make(chan *broadcastResult)
isFirstListener := ctx.broadcaster.AddListener(username, resultChan)
if isFirstListener {
go ctx.getResultAndBroadcast(username, uuid)
} else {
ctx.Logger.IncCounter("mojang_textures.already_scheduled", 1)
}
result := <-resultChan
return result.textures, result.error
}
func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
start := time.Now()
result := ctx.getResult(username, uuid)
ctx.broadcaster.BroadcastAndRemove(username, result)
ctx.Logger.RecordTimer("mojang_textures.result_time", time.Since(start))
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
if uuid == "" {
profile, err := ctx.UuidsProvider.GetUuid(username)
if err != nil {
ctx.handleMojangApiResponseError(err, "usernames")
return &broadcastResult{nil, err}
}
uuid = ""
if profile != nil {
uuid = profile.Id
}
_ = ctx.Storage.StoreUuid(username, uuid)
if uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_miss", 1)
return &broadcastResult{nil, nil}
}
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
textures, err := ctx.TexturesProvider.GetTextures(uuid)
if err != nil {
ctx.handleMojangApiResponseError(err, "textures")
return &broadcastResult{nil, err}
}
// Mojang can respond with an error, but it will still count as a hit,
// therefore store the result even if textures is nil to prevent 429 error
ctx.Storage.StoreTextures(uuid, textures)
if textures != nil {
ctx.Logger.IncCounter("mojang_textures.usernames.textures_hit", 1)
} else {
ctx.Logger.IncCounter("mojang_textures.usernames.textures_miss", 1)
}
return &broadcastResult{textures, nil}
}
func (ctx *Provider) handleMojangApiResponseError(err error, threadName string) {
errParam := wd.ErrParam(err)
threadParam := wd.NameParam(threadName)
ctx.Logger.Debug(":name: Got response error :err", threadParam, errParam)
switch err.(type) {
case mojang.ResponseError:
if _, ok := err.(*mojang.BadRequestError); ok {
ctx.Logger.Warning(":name: Got 400 Bad Request :err", threadParam, errParam)
return
}
if _, ok := err.(*mojang.ForbiddenError); ok {
ctx.Logger.Warning(":name: Got 403 Forbidden :err", threadParam, errParam)
return
}
if _, ok := err.(*mojang.TooManyRequestsError); ok {
ctx.Logger.Warning(":name: Got 429 Too Many Requests :err", threadParam, errParam)
return
}
return
case net.Error:
if err.(net.Error).Timeout() {
return
}
if _, ok := err.(*url.Error); ok {
return
}
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
return
}
if err == syscall.ECONNREFUSED {
return
}
}
ctx.Logger.Emergency(":name: Unknown Mojang response error: :err", threadParam, errParam)
}

View File

@@ -0,0 +1,439 @@
package mojangtextures
import (
"errors"
"net"
"net/url"
"sync"
"syscall"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
mocks "github.com/elyby/chrly/tests"
)
func TestBroadcaster(t *testing.T) {
t.Run("GetOrAppend", func(t *testing.T) {
t.Run("first call when username didn't exist before should return true", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel)
assert.True(isFirstListener)
listeners, ok := broadcaster.listeners["mock"]
assert.True(ok)
assert.Len(listeners, 1)
assert.Equal(channel, listeners[0])
})
t.Run("subsequent calls should return false", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel1 := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel1)
assert.True(isFirstListener)
channel2 := make(chan *broadcastResult)
isFirstListener = broadcaster.AddListener("mock", channel2)
assert.False(isFirstListener)
channel3 := make(chan *broadcastResult)
isFirstListener = broadcaster.AddListener("mock", channel3)
assert.False(isFirstListener)
})
})
t.Run("BroadcastAndRemove", func(t *testing.T) {
t.Run("should broadcast to all listeners and remove the key", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel1 := make(chan *broadcastResult)
channel2 := make(chan *broadcastResult)
broadcaster.AddListener("mock", channel1)
broadcaster.AddListener("mock", channel2)
result := &broadcastResult{}
broadcaster.BroadcastAndRemove("mock", result)
assert.Equal(result, <-channel1)
assert.Equal(result, <-channel2)
channel3 := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel3)
assert.True(isFirstListener)
})
t.Run("call on not exists username", func(t *testing.T) {
assert := testify.New(t)
assert.NotPanics(func() {
broadcaster := createBroadcaster()
broadcaster.BroadcastAndRemove("mock", &broadcastResult{})
})
})
})
}
type mockUuidsProvider struct {
mock.Mock
}
func (m *mockUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
args := m.Called(username)
var result *mojang.ProfileInfo
if casted, ok := args.Get(0).(*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type mockTexturesProvider struct {
mock.Mock
}
func (m *mockTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type mockStorage struct {
mock.Mock
}
func (m *mockStorage) GetUuid(username string) (string, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
}
func (m *mockStorage) StoreUuid(username string, uuid string) error {
args := m.Called(username, uuid)
return args.Error(0)
}
func (m *mockStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
func (m *mockStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
m.Called(uuid, textures)
}
type providerTestSuite struct {
suite.Suite
Provider *Provider
UuidsProvider *mockUuidsProvider
TexturesProvider *mockTexturesProvider
Storage *mockStorage
Logger *mocks.WdMock
}
func (suite *providerTestSuite) SetupTest() {
suite.UuidsProvider = &mockUuidsProvider{}
suite.TexturesProvider = &mockTexturesProvider{}
suite.Storage = &mockStorage{}
suite.Logger = &mocks.WdMock{}
suite.Provider = &Provider{
UuidsProvider: suite.UuidsProvider,
TexturesProvider: suite.TexturesProvider,
Storage: suite.Storage,
Logger: suite.Logger,
}
}
func (suite *providerTestSuite) TearDownTest() {
// time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls
suite.UuidsProvider.AssertExpectations(suite.T())
suite.TexturesProvider.AssertExpectations(suite.T())
suite.Storage.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func TestProvider(t *testing.T) {
suite.Run(t, new(providerTestSuite))
}
func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.textures.cache_hit", int64(1)).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Nil(err)
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "").Once().Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
var expectedResult *mojang.SignedTexturesResponse
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_miss", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Equal(expectedResult, result)
suite.Assert().Nil(err)
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.already_scheduled", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
// If possible, than remove this .After call
suite.UuidsProvider.On("GetUuid", "username").Once().After(time.Millisecond).Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
results := make([]*mojang.SignedTexturesResponse, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
textures, _ := suite.Provider.GetForUsername("username")
results[i] = textures
wg.Done()
}(i)
}
wg.Wait()
suite.Assert().Equal(expectedResult, results[0])
suite.Assert().Equal(expectedResult, results[1])
}
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
suite.Logger.On("IncCounter", "mojang_textures.invalid_username", int64(1)).Once()
result, err := suite.Provider.GetForUsername("Not allowed")
suite.Assert().Error(err, "invalid username")
suite.Assert().Nil(result)
}
type timeoutError struct {
}
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
var expectedErrors = []error{
&mojang.BadRequestError{},
&mojang.ForbiddenError{},
&mojang.TooManyRequestsError{},
&mojang.ServerError{},
&timeoutError{},
&url.Error{Op: "GET", URL: "http://localhost"},
&net.OpError{Op: "read"},
&net.OpError{Op: "dial"},
syscall.ECONNREFUSED,
}
func (suite *providerTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 400 Bad Request :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 403 Forbidden :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
for _, err := range expectedErrors {
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
suite.UuidsProvider.AssertExpectations(suite.T())
suite.UuidsProvider.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *providerTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, errors.New("unexpected error"))
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
}
func (suite *providerTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 400 Bad Request :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 403 Forbidden :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
// suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, &ValueNotFound{})
// suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", (*mojang.SignedTexturesResponse)(nil))
for _, err := range expectedErrors {
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
suite.UuidsProvider.AssertExpectations(suite.T())
suite.TexturesProvider.AssertExpectations(suite.T())
suite.UuidsProvider.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
suite.TexturesProvider.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *providerTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, errors.New("unexpected error"))
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
}

61
mojangtextures/storage.go Normal file
View File

@@ -0,0 +1,61 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
// UuidsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// used to reduce the load on the account information queue
type UuidsStorage interface {
// Since only primitive types are used in this method, you should return a special error ValueNotFound
// to return the information that no error has occurred and username does not have uuid
GetUuid(username string) (string, error)
// An empty uuid value can be passed if the corresponding account has not been found
StoreUuid(username string, uuid string) error
}
// TexturesStorage is a Mojang's textures storage, used as a values cache to avoid 429 errors
type TexturesStorage interface {
// Error should not have nil value only if the repository failed to determine if there are any textures
// for this uuid or not at all. If there is information about the absence of textures, nil nil should be returned
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
// The nil value can be passed when there are no textures for the corresponding uuid and we know about it
StoreTextures(uuid string, textures *mojang.SignedTexturesResponse)
}
type Storage interface {
UuidsStorage
TexturesStorage
}
// SeparatedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SeparatedStorage struct {
UuidsStorage
TexturesStorage
}
func (s *SeparatedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
}
func (s *SeparatedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
}
func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
return s.TexturesStorage.GetTextures(uuid)
}
func (s *SeparatedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -0,0 +1,89 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
type uuidsStorageMock struct {
mock.Mock
}
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
}
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error {
m.Called(username, uuid)
return nil
}
type texturesStorageMock struct {
mock.Mock
}
func (m *texturesStorageMock) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
func (m *texturesStorageMock) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
m.Called(uuid, textures)
}
func TestSplittedStorage(t *testing.T) {
createMockedStorage := func() (*SeparatedStorage, *uuidsStorageMock, *texturesStorageMock) {
uuidsStorage := &uuidsStorageMock{}
texturesStorage := &texturesStorageMock{}
return &SeparatedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage
}
t.Run("GetUuid", func(t *testing.T) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("GetUuid", "username").Once().Return("find me", nil)
result, err := storage.GetUuid("username")
assert.Nil(t, err)
assert.Equal(t, "find me", result)
uuidsMock.AssertExpectations(t)
})
t.Run("StoreUuid", func(t *testing.T) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("StoreUuid", "username", "result").Once()
_ = storage.StoreUuid("username", "result")
uuidsMock.AssertExpectations(t)
})
t.Run("GetTextures", func(t *testing.T) {
result := &mojang.SignedTexturesResponse{Id: "mock id"}
storage, _, texturesMock := createMockedStorage()
texturesMock.On("GetTextures", "uuid").Once().Return(result, nil)
returned, err := storage.GetTextures("uuid")
assert.Nil(t, err)
assert.Equal(t, result, returned)
texturesMock.AssertExpectations(t)
})
t.Run("StoreTextures", func(t *testing.T) {
toStore := &mojang.SignedTexturesResponse{}
storage, _, texturesMock := createMockedStorage()
texturesMock.On("StoreTextures", "mock id", toStore).Once()
storage.StoreTextures("mock id", toStore)
texturesMock.AssertExpectations(t)
})
}
func TestValueNotFound_Error(t *testing.T) {
err := &ValueNotFound{}
assert.Equal(t, "value not found in the storage", err.Error())
}