diff --git a/Gopkg.lock b/Gopkg.lock index 4ac5f62..2acaa88 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -227,12 +227,12 @@ version = "v1.0.0" [[projects]] - digest = "1:3926a4ec9a4ff1a072458451aa2d9b98acd059a45b38f7335d31e06c3d6a0159" + digest = "1:381bcbeb112a51493d9d998bbba207a529c73dbb49b3fd789e48c63fac1f192c" name = "github.com/stretchr/testify" packages = ["assert"] pruneopts = "" - revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" - version = "v1.1.4" + revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" + version = "v1.3.0" [[projects]] branch = "issue-18" diff --git a/Gopkg.toml b/Gopkg.toml index 868b732..42782f1 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -33,7 +33,7 @@ ignored = ["github.com/elyby/chrly"] [[constraint]] name = "github.com/stretchr/testify" - version = "^1.1.4" + version = "^1.3.0" [[constraint]] name = "github.com/golang/mock" diff --git a/api/mojang/queue/queue.go b/api/mojang/queue/queue.go index 5ad9101..ad517cc 100644 --- a/api/mojang/queue/queue.go +++ b/api/mojang/queue/queue.go @@ -10,6 +10,7 @@ import ( var usernamesToUuids = mojang.UsernamesToUuids var uuidToTextures = mojang.UuidToTextures +var delay = time.Second type JobsQueue struct { Storage Storage @@ -18,24 +19,26 @@ type JobsQueue struct { queue jobsQueue } -func (ctx *JobsQueue) GetTexturesForUsername(username string) (resultChan chan *mojang.SignedTexturesResponse) { +func (ctx *JobsQueue) GetTexturesForUsername(username string) *mojang.SignedTexturesResponse { ctx.onFirstCall.Do(func() { ctx.queue.New() ctx.startQueue() }) + resultChan := make(chan *mojang.SignedTexturesResponse) // TODO: prevent of adding the same username more than once ctx.queue.Enqueue(&jobItem{username, resultChan}) - return + return <-resultChan } func (ctx *JobsQueue) startQueue() { go func() { - for { + time.Sleep(delay) + for true { start := time.Now() ctx.queueRound() - time.Sleep(time.Second - time.Since(start)) + time.Sleep(delay - time.Since(start)) } }() } @@ -66,7 +69,7 @@ func (ctx *JobsQueue) queueRound() { var wg sync.WaitGroup for _, job := range jobs { wg.Add(1) - go func() { + go func(job *jobItem) { var result *mojang.SignedTexturesResponse shouldCache := true var uuid string @@ -95,7 +98,7 @@ func (ctx *JobsQueue) queueRound() { if shouldCache { // TODO: store result to cache } - }() + }(job) } wg.Wait() diff --git a/api/mojang/queue/queue_test.go b/api/mojang/queue/queue_test.go new file mode 100644 index 0000000..969c98f --- /dev/null +++ b/api/mojang/queue/queue_test.go @@ -0,0 +1,215 @@ +package queue + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "log" + "testing" + "time" + + "github.com/elyby/chrly/api/mojang" + testify "github.com/stretchr/testify/assert" +) + +func TestJobsQueue_GetTexturesForUsername(t *testing.T) { + delay = 50 * time.Millisecond + + t.Run("receive textures for one username", func(t *testing.T) { + assert := testify.New(t) + + usernamesToUuids = createUsernameToUuidsMock( + assert, + []string{"maksimkurb"}, + []*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, + nil, + ) + uuidToTextures = createUuidToTextures([]*createUuidToTexturesResult{ + createTexturesResult("0d252b7218b648bfb86c2ae476954d32", "maksimkurb"), + }) + + queue := &JobsQueue{Storage: &NilStorage{}} + result := queue.GetTexturesForUsername("maksimkurb") + + if assert.NotNil(result) { + assert.Equal("0d252b7218b648bfb86c2ae476954d32", result.Id) + assert.Equal("maksimkurb", result.Name) + } + }) + + t.Run("receive textures for few usernames", func(t *testing.T) { + assert := testify.New(t) + + usernamesToUuids = createUsernameToUuidsMock( + assert, + []string{"maksimkurb", "Thinkofdeath"}, + []*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + {Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"}, + }, + nil, + ) + uuidToTextures = createUuidToTextures([]*createUuidToTexturesResult{ + createTexturesResult("0d252b7218b648bfb86c2ae476954d32", "maksimkurb"), + createTexturesResult("4566e69fc90748ee8d71d7ba5aa00d20", "Thinkofdeath"), + }) + + queue := &JobsQueue{Storage: &NilStorage{}} + resultChan1 := make(chan *mojang.SignedTexturesResponse) + resultChan2 := make(chan *mojang.SignedTexturesResponse) + go func() { + resultChan1 <- queue.GetTexturesForUsername("maksimkurb") + }() + go func() { + resultChan2 <- queue.GetTexturesForUsername("Thinkofdeath") + }() + + assert.NotNil(<-resultChan1) + assert.NotNil(<-resultChan2) + }) + + t.Run("query no more than 100 usernames and all left on the next iteration", func(t *testing.T) { + assert := testify.New(t) + + usernames := make([]string, 120, 120) + for i := 0; i < 120; i++ { + usernames[i] = randStr(8) + } + + usernamesToUuids = createUsernameToUuidsMock(assert, usernames[0:100], []*mojang.ProfileInfo{}, nil) + + queue := &JobsQueue{Storage: &NilStorage{}} + + scheduleUsername := func(username string) { + queue.GetTexturesForUsername(username) + } + + for _, username := range usernames { + go scheduleUsername(username) + time.Sleep(50 * time.Microsecond) // Add delay to have consistent order + } + + // Let it begin first iteration + time.Sleep(delay + delay/2) + + usernamesToUuids = createUsernameToUuidsMock( + assert, + usernames[100:120], + []*mojang.ProfileInfo{}, + nil, + ) + + time.Sleep(delay) + }) + + t.Run("should do nothing if queue is empty", func(t *testing.T) { + assert := testify.New(t) + + usernamesToUuids = createUsernameToUuidsMock(assert, []string{"maksimkurb"}, []*mojang.ProfileInfo{}, nil) + uuidToTextures = func(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) { + t.Error("this method shouldn't be called") + return nil, nil + } + + // Perform first iteration and await it finish + queue := &JobsQueue{Storage: &NilStorage{}} + result := queue.GetTexturesForUsername("maksimkurb") + assert.Nil(result) + + // Override external API call that indicates, that queue is still trying to obtain somethid + usernamesToUuids = func(usernames []string) ([]*mojang.ProfileInfo, error) { + t.Error("this method shouldn't be called") + return nil, nil + } + + // Let it to iterate few times + time.Sleep(delay * 2) + }) + + t.Run("handle 429 error when exchanging usernames to uuids", func(t *testing.T) { + assert := testify.New(t) + + usernamesToUuids = createUsernameToUuidsMock(assert, []string{"maksimkurb"}, nil, &mojang.TooManyRequestsError{}) + + queue := &JobsQueue{Storage: &NilStorage{}} + result := queue.GetTexturesForUsername("maksimkurb") + assert.Nil(result) + }) + + t.Run("handle 429 error when requesting user's textures", func(t *testing.T) { + assert := testify.New(t) + + usernamesToUuids = createUsernameToUuidsMock( + assert, + []string{"maksimkurb"}, + []*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, + nil, + ) + uuidToTextures = createUuidToTextures([]*createUuidToTexturesResult{ + createTexturesResult("0d252b7218b648bfb86c2ae476954d32", &mojang.TooManyRequestsError{}), + }) + + queue := &JobsQueue{Storage: &NilStorage{}} + result := queue.GetTexturesForUsername("maksimkurb") + assert.Nil(result) + }) +} + +func createUsernameToUuidsMock( + assert *testify.Assertions, + expectedUsernames []string, + result []*mojang.ProfileInfo, + err error, +) func(usernames []string) ([]*mojang.ProfileInfo, error) { + return func(usernames []string) ([]*mojang.ProfileInfo, error) { + assert.ElementsMatch(expectedUsernames, usernames) + return result, err + } +} + +type createUuidToTexturesResult struct { + uuid string + result *mojang.SignedTexturesResponse + err error +} + +func createTexturesResult(uuid string, result interface{}) *createUuidToTexturesResult { + output := &createUuidToTexturesResult{uuid: uuid} + if username, ok := result.(string); ok { + output.result = &mojang.SignedTexturesResponse{Id: uuid, Name: username} + } else if err, ok := result.(error); ok { + output.err = err + } else { + log.Fatal("invalid result type passed") + } + + return output +} + +func createUuidToTextures( + results []*createUuidToTexturesResult, +) func(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) { + return func(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) { + for _, result := range results { + if result.uuid == uuid { + return result.result, result.err + } + } + + return nil, errors.New("cannot find corresponding result") + } +} + +// https://stackoverflow.com/a/50581165 +func randStr(len int) string { + buff := make([]byte, len) + rand.Read(buff) + str := base64.StdEncoding.EncodeToString(buff) + + // Base 64 can be longer than len + return str[:len] +}