mirror of
https://github.com/elyby/chrly.git
synced 2025-03-12 02:49:26 +05:30
Rewrite mojang textures provider module, cleanup its implementation of events emitter, statsd and etc.
This commit is contained in:
parent
4cdc151ab3
commit
dac5e4967f
@ -1,313 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/h2non/gock"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSignedTexturesResponse(t *testing.T) {
|
||||
t.Run("DecodeTextures", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
},
|
||||
},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
|
||||
})
|
||||
|
||||
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Nil(t, textures)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsernamesToUuids(t *testing.T) {
|
||||
t.Run("exchange usernames to uuids", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
JSON([]string{"Thinkofdeath", "maksimkurb"}).
|
||||
Reply(200).
|
||||
JSON([]map[string]interface{}{
|
||||
{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"legacy": false,
|
||||
"demo": true,
|
||||
},
|
||||
{
|
||||
"id": "0d252b7218b648bfb86c2ae476954d32",
|
||||
"name": "maksimkurb",
|
||||
// There is no legacy or demo fields
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
if assert.NoError(err) {
|
||||
assert.Len(result, 2)
|
||||
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
|
||||
assert.Equal("Thinkofdeath", result[0].Name)
|
||||
assert.False(result[0].IsLegacy)
|
||||
assert.True(result[0].IsDemo)
|
||||
|
||||
assert.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
|
||||
assert.Equal("maksimkurb", result[1].Name)
|
||||
assert.False(result[1].IsLegacy)
|
||||
assert.False(result[1].IsDemo)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handle bad request response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(400).
|
||||
JSON(map[string]interface{}{
|
||||
"error": "IllegalArgumentException",
|
||||
"errorMessage": "profileName can not be null or empty.",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{""})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&BadRequestError{}, err)
|
||||
assert.EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle forbidden response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(403).
|
||||
BodyString("just because")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&ForbiddenError{}, err)
|
||||
assert.EqualError(err, "403: Forbidden")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle too many requests response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(429).
|
||||
JSON(map[string]interface{}{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&TooManyRequestsError{}, err)
|
||||
assert.EqualError(err, "429: Too Many Requests")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle server error", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&ServerError{}, err)
|
||||
assert.EqualError(err, "500: Server error")
|
||||
assert.Equal(500, err.(*ServerError).Status)
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUuidToTextures(t *testing.T) {
|
||||
t.Run("obtain not signed textures", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "textures",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
if assert.NoError(err) {
|
||||
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
assert.Equal("Thinkofdeath", result.Name)
|
||||
assert.Equal(1, len(result.Props))
|
||||
assert.Equal("textures", result.Props[0].Name)
|
||||
assert.Equal(476, len(result.Props[0].Value))
|
||||
assert.Equal("", result.Props[0].Signature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("obtain signed textures with dashed uuid", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
MatchParam("unsigned", "false").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "textures",
|
||||
"signature": "signature string",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69f-c907-48ee-8d71-d7ba5aa00d20", true)
|
||||
if assert.NoError(err) {
|
||||
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
assert.Equal("Thinkofdeath", result.Name)
|
||||
assert.Equal(1, len(result.Props))
|
||||
assert.Equal("textures", result.Props[0].Name)
|
||||
assert.Equal(476, len(result.Props[0].Value))
|
||||
assert.Equal("signature string", result.Props[0].Signature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handle empty response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(204).
|
||||
BodyString("")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
assert.Nil(result)
|
||||
assert.IsType(&EmptyResponse{}, err)
|
||||
assert.EqualError(err, "204: Empty Response")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle too many requests response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(429).
|
||||
JSON(map[string]interface{}{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
assert.Nil(result)
|
||||
assert.IsType(&TooManyRequestsError{}, err)
|
||||
assert.EqualError(err, "429: Too Many Requests")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle server error", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
assert.Nil(result)
|
||||
assert.IsType(&ServerError{}, err)
|
||||
assert.EqualError(err, "500: Server error")
|
||||
assert.Equal(500, err.(*ServerError).Status)
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type TexturesProp struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Textures *TexturesResponse `json:"textures"`
|
||||
}
|
||||
|
||||
type TexturesResponse struct {
|
||||
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
|
||||
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CapeTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
|
||||
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *TexturesProp
|
||||
err = json.Unmarshal(jsonStr, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EncodeTextures(textures *TexturesProp) string {
|
||||
jsonSerialized, _ := json.Marshal(textures)
|
||||
return base64.URLEncoding.EncodeToString(jsonSerialized)
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type texturesTestCase struct {
|
||||
Name string
|
||||
Encoded string
|
||||
Decoded *TexturesProp
|
||||
}
|
||||
|
||||
var texturesTestCases = []*texturesTestCase{
|
||||
{
|
||||
Name: "property without textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856010494),
|
||||
Textures: &TexturesResponse{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with classic skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856307412),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with alex skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856494791),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
|
||||
Metadata: &SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with skin and cape textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "d90b68bc81724329a047f1186dcd4336",
|
||||
ProfileName: "akronman1",
|
||||
Timestamp: int64(1555857675335),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
|
||||
},
|
||||
Cape: &CapeTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestDecodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("decode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures(testCase.Encoded)
|
||||
assert.Nil(err)
|
||||
assert.Equal(testCase.Decoded, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("invalid base64")
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
|
||||
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("encode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result := EncodeTextures(testCase.Decoded)
|
||||
assert.Equal(testCase.Encoded, result)
|
||||
})
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ func New(basePath string) (*Filesystem, error) {
|
||||
return &Filesystem{path: basePath}, nil
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
type Filesystem struct {
|
||||
path string
|
||||
}
|
||||
|
@ -217,64 +217,76 @@ func removeByUsername(ctx context.Context, conn radix.Conn, username string) err
|
||||
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
}
|
||||
|
||||
func (db *Redis) GetUuid(username string) (string, bool, error) {
|
||||
func (db *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
|
||||
var uuid string
|
||||
var found bool
|
||||
foundUsername := username
|
||||
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
uuid, found, err = findMojangUuidByUsername(ctx, conn, username)
|
||||
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return uuid, found, err
|
||||
return uuid, foundUsername, err
|
||||
}
|
||||
|
||||
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, bool, error) {
|
||||
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
|
||||
key := strings.ToLower(username)
|
||||
var result string
|
||||
err := conn.Do(ctx, radix.Cmd(&result, "HGET", mojangUsernameToUuidKey, key))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return "", false, nil
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(result, ":")
|
||||
partsCnt := len(parts)
|
||||
// https://github.com/elyby/chrly/issues/28
|
||||
if len(parts) < 2 {
|
||||
if partsCnt < 2 {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return "", false, fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result)
|
||||
return "", "", fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result)
|
||||
}
|
||||
|
||||
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
var casedUsername, uuid, rawTimestamp string
|
||||
if partsCnt == 2 { // Legacy, when original username wasn't stored
|
||||
casedUsername = username
|
||||
uuid = parts[0]
|
||||
rawTimestamp = parts[1]
|
||||
} else {
|
||||
casedUsername = parts[0]
|
||||
uuid = parts[1]
|
||||
rawTimestamp = parts[2]
|
||||
}
|
||||
|
||||
timestamp, _ := strconv.ParseInt(rawTimestamp, 10, 64)
|
||||
storedAt := time.Unix(timestamp, 0)
|
||||
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
return parts[0], true, nil
|
||||
return uuid, casedUsername, nil
|
||||
}
|
||||
|
||||
func (db *Redis) StoreUuid(username string, uuid string) error {
|
||||
func (db *Redis) StoreMojangUuid(username string, uuid string) error {
|
||||
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return storeMojangUuid(ctx, conn, username, uuid)
|
||||
}))
|
||||
}
|
||||
|
||||
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
|
||||
value := uuid + ":" + strconv.FormatInt(now().Unix(), 10)
|
||||
value := fmt.Sprintf("%s:%s:%d", username, uuid, now().Unix())
|
||||
err := conn.Do(ctx, radix.Cmd(nil, "HSET", mojangUsernameToUuidKey, strings.ToLower(username), value))
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -328,15 +328,28 @@ func (suite *redisTestSuite) TestRemoveSkinByUsername() {
|
||||
|
||||
func (suite *redisTestSuite) TestGetUuid() {
|
||||
suite.RunSubTest("exists record", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
|
||||
)
|
||||
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("MoCk", username)
|
||||
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists record (legacy data)", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
|
||||
)
|
||||
|
||||
uuid, found, err := suite.Redis.GetUuid("Mock")
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().True(found)
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("Mock", username)
|
||||
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
})
|
||||
|
||||
@ -344,19 +357,19 @@ func (suite *redisTestSuite) TestGetUuid() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf(":%d", time.Now().Unix()),
|
||||
fmt.Sprintf("%s::%d", "MoCk", time.Now().Unix()),
|
||||
)
|
||||
|
||||
uuid, found, err := suite.Redis.GetUuid("Mock")
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().True(found)
|
||||
suite.Require().Empty("", uuid)
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("MoCk", username)
|
||||
suite.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("not exists record", func() {
|
||||
uuid, found, err := suite.Redis.GetUuid("Mock")
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().False(found)
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Empty(username)
|
||||
suite.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
@ -364,13 +377,13 @@ func (suite *redisTestSuite) TestGetUuid() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
|
||||
fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
|
||||
)
|
||||
|
||||
uuid, found, err := suite.Redis.GetUuid("Mock")
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Empty(uuid)
|
||||
suite.Require().False(found)
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().Empty(username)
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Empty(resp, "should cleanup expired records")
|
||||
@ -383,9 +396,9 @@ func (suite *redisTestSuite) TestGetUuid() {
|
||||
"corrupted value",
|
||||
)
|
||||
|
||||
uuid, found, err := suite.Redis.GetUuid("Mock")
|
||||
uuid, found, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().Empty(uuid)
|
||||
suite.Require().False(found)
|
||||
suite.Require().Empty(found)
|
||||
suite.Require().Error(err, "Got unexpected response from the mojangUsernameToUuid hash: \"corrupted value\"")
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
@ -399,11 +412,11 @@ func (suite *redisTestSuite) TestStoreUuid() {
|
||||
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
|
||||
}
|
||||
|
||||
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
|
||||
err := suite.Redis.StoreMojangUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
|
||||
suite.Require().Nil(err)
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Equal(resp, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
|
||||
suite.Require().Equal(resp, "Mock:d3ca513eb3e14946b58047f2bd3530fd:1587435016")
|
||||
})
|
||||
|
||||
suite.RunSubTest("store empty uuid", func() {
|
||||
@ -411,11 +424,11 @@ func (suite *redisTestSuite) TestStoreUuid() {
|
||||
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
|
||||
}
|
||||
|
||||
err := suite.Redis.StoreUuid("Mock", "")
|
||||
err := suite.Redis.StoreMojangUuid("Mock", "")
|
||||
suite.Require().Nil(err)
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Equal(resp, ":1587435016")
|
||||
suite.Require().Equal(resp, "Mock::1587435016")
|
||||
})
|
||||
}
|
||||
|
||||
|
9
di/db.go
9
di/db.go
@ -12,7 +12,7 @@ import (
|
||||
"github.com/elyby/chrly/db/redis"
|
||||
es "github.com/elyby/chrly/eventsubscribers"
|
||||
"github.com/elyby/chrly/http"
|
||||
"github.com/elyby/chrly/mojangtextures"
|
||||
"github.com/elyby/chrly/mojang"
|
||||
)
|
||||
|
||||
// v4 had the idea that it would be possible to separate backends for storing skins and capes.
|
||||
@ -23,12 +23,11 @@ import (
|
||||
var db = di.Options(
|
||||
di.Provide(newRedis,
|
||||
di.As(new(http.SkinsRepository)),
|
||||
di.As(new(mojangtextures.UUIDsStorage)),
|
||||
di.As(new(mojang.MojangUuidsStorage)),
|
||||
),
|
||||
di.Provide(newFSFactory,
|
||||
di.As(new(http.CapesRepository)),
|
||||
),
|
||||
di.Provide(newMojangSignedTexturesStorage),
|
||||
)
|
||||
|
||||
func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) {
|
||||
@ -66,7 +65,3 @@ func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) {
|
||||
config.GetString("storage.filesystem.capesDirName"),
|
||||
))
|
||||
}
|
||||
|
||||
func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage {
|
||||
return mojangtextures.NewInMemoryTexturesStorage()
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import (
|
||||
d "github.com/elyby/chrly/dispatcher"
|
||||
"github.com/elyby/chrly/eventsubscribers"
|
||||
"github.com/elyby/chrly/http"
|
||||
"github.com/elyby/chrly/mojangtextures"
|
||||
)
|
||||
|
||||
var dispatcher = di.Options(
|
||||
@ -15,7 +14,6 @@ var dispatcher = di.Options(
|
||||
di.As(new(d.Emitter)),
|
||||
di.As(new(d.Subscriber)),
|
||||
di.As(new(http.Emitter)),
|
||||
di.As(new(mojangtextures.Emitter)),
|
||||
di.As(new(eventsubscribers.Subscriber)),
|
||||
),
|
||||
di.Invoke(enableEventsHandlers),
|
||||
|
@ -1,67 +1,56 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/defval/di"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
es "github.com/elyby/chrly/eventsubscribers"
|
||||
"github.com/elyby/chrly/http"
|
||||
"github.com/elyby/chrly/mojangtextures"
|
||||
chrlyHttp "github.com/elyby/chrly/http"
|
||||
"github.com/elyby/chrly/mojang"
|
||||
)
|
||||
|
||||
var mojangTextures = di.Options(
|
||||
di.Invoke(interceptMojangApiUrls),
|
||||
di.Provide(newMojangApi),
|
||||
di.Provide(newMojangTexturesProviderFactory),
|
||||
di.Provide(newMojangTexturesProvider),
|
||||
di.Provide(newMojangTexturesUuidsProviderFactory),
|
||||
di.Provide(newMojangTexturesBatchUUIDsProvider),
|
||||
di.Provide(newMojangTexturesBatchUUIDsProviderStrategyFactory),
|
||||
di.Provide(newMojangTexturesBatchUUIDsProviderDelayedStrategy),
|
||||
di.Provide(newMojangTexturesBatchUUIDsProviderFullBusStrategy),
|
||||
di.Provide(newMojangSignedTexturesProvider),
|
||||
di.Provide(newMojangTexturesStorageFactory),
|
||||
)
|
||||
|
||||
func interceptMojangApiUrls(config *viper.Viper) error {
|
||||
apiUrl := config.GetString("mojang.api_base_url")
|
||||
if apiUrl != "" {
|
||||
u, err := url.ParseRequestURI(apiUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
func newMojangApi(config *viper.Viper) (*mojang.MojangApi, error) {
|
||||
batchUuidsUrl := config.GetString("mojang.batch_uuids_url")
|
||||
if batchUuidsUrl != "" {
|
||||
if _, err := url.ParseRequestURI(batchUuidsUrl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mojang.ApiMojangDotComAddr = u.String()
|
||||
}
|
||||
|
||||
sessionServerUrl := config.GetString("mojang.session_server_base_url")
|
||||
if sessionServerUrl != "" {
|
||||
u, err := url.ParseRequestURI(apiUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
profileUrl := config.GetString("mojang.profile_url")
|
||||
if profileUrl != "" {
|
||||
if _, err := url.ParseRequestURI(batchUuidsUrl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mojang.SessionServerMojangComAddr = u.String()
|
||||
}
|
||||
|
||||
return nil
|
||||
httpClient := &http.Client{} // TODO: extract to the singleton dependency
|
||||
|
||||
return mojang.NewMojangApi(httpClient, batchUuidsUrl, profileUrl), nil
|
||||
}
|
||||
|
||||
func newMojangTexturesProviderFactory(
|
||||
container *di.Container,
|
||||
config *viper.Viper,
|
||||
) (http.MojangTexturesProvider, error) {
|
||||
) (chrlyHttp.MojangTexturesProvider, error) {
|
||||
config.SetDefault("mojang_textures.enabled", true)
|
||||
if !config.GetBool("mojang_textures.enabled") {
|
||||
return &mojangtextures.NilProvider{}, nil
|
||||
return &mojang.NilProvider{}, nil
|
||||
}
|
||||
|
||||
var provider *mojangtextures.Provider
|
||||
var provider *mojang.MojangTexturesProvider
|
||||
err := container.Resolve(&provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -71,125 +60,49 @@ func newMojangTexturesProviderFactory(
|
||||
}
|
||||
|
||||
func newMojangTexturesProvider(
|
||||
emitter mojangtextures.Emitter,
|
||||
uuidsProvider mojangtextures.UUIDsProvider,
|
||||
texturesProvider mojangtextures.TexturesProvider,
|
||||
storage mojangtextures.Storage,
|
||||
) *mojangtextures.Provider {
|
||||
return &mojangtextures.Provider{
|
||||
Emitter: emitter,
|
||||
UUIDsProvider: uuidsProvider,
|
||||
uuidsProvider mojang.UuidsProvider,
|
||||
texturesProvider mojang.TexturesProvider,
|
||||
) *mojang.MojangTexturesProvider {
|
||||
return &mojang.MojangTexturesProvider{
|
||||
UuidsProvider: uuidsProvider,
|
||||
TexturesProvider: texturesProvider,
|
||||
Storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
func newMojangTexturesUuidsProviderFactory(
|
||||
container *di.Container,
|
||||
) (mojangtextures.UUIDsProvider, error) {
|
||||
var provider *mojangtextures.BatchUuidsProvider
|
||||
err := container.Resolve(&provider)
|
||||
|
||||
return provider, err
|
||||
batchProvider *mojang.BatchUuidsProvider,
|
||||
uuidsStorage mojang.MojangUuidsStorage,
|
||||
) mojang.UuidsProvider {
|
||||
return &mojang.UuidsProviderWithCache{
|
||||
Provider: batchProvider,
|
||||
Storage: uuidsStorage,
|
||||
}
|
||||
}
|
||||
|
||||
func newMojangTexturesBatchUUIDsProvider(
|
||||
container *di.Container,
|
||||
strategy mojangtextures.BatchUuidsProviderStrategy,
|
||||
emitter mojangtextures.Emitter,
|
||||
) (*mojangtextures.BatchUuidsProvider, error) {
|
||||
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
|
||||
config.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute)
|
||||
|
||||
return &namedHealthChecker{
|
||||
Name: "mojang-batch-uuids-provider-response",
|
||||
Checker: es.MojangBatchUuidsProviderResponseChecker(
|
||||
emitter,
|
||||
config.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"),
|
||||
),
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
|
||||
config.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50)
|
||||
|
||||
return &namedHealthChecker{
|
||||
Name: "mojang-batch-uuids-provider-queue-length",
|
||||
Checker: es.MojangBatchUuidsProviderQueueLengthChecker(
|
||||
emitter,
|
||||
config.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"),
|
||||
),
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mojangtextures.NewBatchUuidsProvider(context.Background(), strategy, emitter), nil
|
||||
}
|
||||
|
||||
func newMojangTexturesBatchUUIDsProviderStrategyFactory(
|
||||
container *di.Container,
|
||||
mojangApi *mojang.MojangApi,
|
||||
config *viper.Viper,
|
||||
) (mojangtextures.BatchUuidsProviderStrategy, error) {
|
||||
) (*mojang.BatchUuidsProvider, error) {
|
||||
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
|
||||
config.SetDefault("queue.batch_size", 10)
|
||||
config.SetDefault("queue.strategy", "periodic")
|
||||
|
||||
strategyName := config.GetString("queue.strategy")
|
||||
switch strategyName {
|
||||
case "periodic":
|
||||
var strategy *mojangtextures.PeriodicStrategy
|
||||
err := container.Resolve(&strategy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: healthcheck is broken
|
||||
|
||||
return strategy, nil
|
||||
case "full-bus":
|
||||
var strategy *mojangtextures.FullBusStrategy
|
||||
err := container.Resolve(&strategy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuidsProvider := mojang.NewBatchUuidsProvider(
|
||||
mojangApi.UsernamesToUuids,
|
||||
config.GetInt("queue.batch_size"),
|
||||
config.GetDuration("queue.loop_delay"),
|
||||
config.GetString("queue.strategy") == "full-bus",
|
||||
)
|
||||
|
||||
return strategy, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown queue strategy \"%s\"", strategyName)
|
||||
}
|
||||
return uuidsProvider, nil
|
||||
}
|
||||
|
||||
func newMojangTexturesBatchUUIDsProviderDelayedStrategy(config *viper.Viper) *mojangtextures.PeriodicStrategy {
|
||||
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
|
||||
config.SetDefault("queue.batch_size", 10)
|
||||
|
||||
return mojangtextures.NewPeriodicStrategy(
|
||||
config.GetDuration("queue.loop_delay"),
|
||||
config.GetInt("queue.batch_size"),
|
||||
func newMojangSignedTexturesProvider(mojangApi *mojang.MojangApi) mojang.TexturesProvider {
|
||||
return mojang.NewTexturesProviderWithInMemoryCache(
|
||||
&mojang.MojangApiTexturesProvider{
|
||||
MojangApiTexturesEndpoint: mojangApi.UuidToTextures,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func newMojangTexturesBatchUUIDsProviderFullBusStrategy(config *viper.Viper) *mojangtextures.FullBusStrategy {
|
||||
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
|
||||
config.SetDefault("queue.batch_size", 10)
|
||||
|
||||
return mojangtextures.NewFullBusStrategy(
|
||||
config.GetDuration("queue.loop_delay"),
|
||||
config.GetInt("queue.batch_size"),
|
||||
)
|
||||
}
|
||||
|
||||
func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider {
|
||||
return &mojangtextures.MojangApiTexturesProvider{
|
||||
Emitter: emitter,
|
||||
}
|
||||
}
|
||||
|
||||
func newMojangTexturesStorageFactory(
|
||||
uuidsStorage mojangtextures.UUIDsStorage,
|
||||
texturesStorage mojangtextures.TexturesStorage,
|
||||
) mojangtextures.Storage {
|
||||
return &mojangtextures.SeparatedStorage{
|
||||
UUIDsStorage: uuidsStorage,
|
||||
TexturesStorage: texturesStorage,
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/etherlabsio/healthcheck/v2"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
type Pingable interface {
|
||||
@ -31,55 +29,6 @@ func DatabaseChecker(connection Pingable) healthcheck.CheckerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func MojangBatchUuidsProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
|
||||
errHolder := &expiringErrHolder{D: resetDuration}
|
||||
dispatcher.Subscribe(
|
||||
"mojang_textures:batch_uuids_provider:result",
|
||||
func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
|
||||
errHolder.Set(err)
|
||||
},
|
||||
)
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
return errHolder.Get()
|
||||
}
|
||||
}
|
||||
|
||||
func MojangBatchUuidsProviderQueueLengthChecker(dispatcher Subscriber, maxLength int) healthcheck.CheckerFunc {
|
||||
var mutex sync.Mutex
|
||||
queueLength := 0
|
||||
dispatcher.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, tasksInQueue int) {
|
||||
mutex.Lock()
|
||||
queueLength = tasksInQueue
|
||||
mutex.Unlock()
|
||||
})
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
if queueLength < maxLength {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("the maximum number of tasks in the queue has been exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
func MojangApiTexturesProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
|
||||
errHolder := &expiringErrHolder{D: resetDuration}
|
||||
dispatcher.Subscribe(
|
||||
"mojang_textures:mojang_api_textures_provider:after_request",
|
||||
func(uuid string, profile *mojang.SignedTexturesResponse, err error) {
|
||||
errHolder.Set(err)
|
||||
},
|
||||
)
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
return errHolder.Get()
|
||||
}
|
||||
}
|
||||
|
||||
type expiringErrHolder struct {
|
||||
D time.Duration
|
||||
err error
|
||||
|
@ -8,9 +8,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/dispatcher"
|
||||
)
|
||||
|
||||
type pingableMock struct {
|
||||
@ -51,98 +48,3 @@ func TestDatabaseChecker(t *testing.T) {
|
||||
close(waitChan)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMojangBatchUuidsProviderChecker(t *testing.T) {
|
||||
t.Run("empty state", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("when no error occurred", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
|
||||
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("when error occurred", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
|
||||
err := errors.New("some error occurred")
|
||||
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
|
||||
assert.Equal(t, err, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should reset value after passed duration", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond)
|
||||
err := errors.New("some error occurred")
|
||||
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
|
||||
assert.Equal(t, err, checker(context.Background()))
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
|
||||
t.Run("empty state", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("less than allowed limit", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
|
||||
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("greater than allowed limit", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
|
||||
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10)
|
||||
checkResult := checker(context.Background())
|
||||
if assert.Error(t, checkResult) {
|
||||
assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMojangApiTexturesProviderResponseChecker(t *testing.T) {
|
||||
t.Run("empty state", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("when no error occurred", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
|
||||
d.Emit("mojang_textures:mojang_api_textures_provider:after_request",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
&mojang.SignedTexturesResponse{},
|
||||
nil,
|
||||
)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("when error occurred", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
|
||||
err := errors.New("some error occurred")
|
||||
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
|
||||
assert.Equal(t, err, checker(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should reset value after passed duration", func(t *testing.T) {
|
||||
d := dispatcher.New()
|
||||
checker := MojangApiTexturesProviderResponseChecker(d, 20*time.Millisecond)
|
||||
err := errors.New("some error occurred")
|
||||
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
|
||||
assert.Equal(t, err, checker(context.Background()))
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
assert.Nil(t, checker(context.Background()))
|
||||
})
|
||||
}
|
||||
|
@ -1,16 +1,11 @@
|
||||
package eventsubscribers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/mono83/slf"
|
||||
"github.com/mono83/slf/wd"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
@ -19,9 +14,6 @@ type Logger struct {
|
||||
|
||||
func (l *Logger) ConfigureWithDispatcher(d Subscriber) {
|
||||
d.Subscribe("skinsystem:after_request", l.handleAfterSkinsystemRequest)
|
||||
|
||||
d.Subscribe("mojang_textures:usernames:after_call", l.createMojangTexturesErrorHandler("usernames"))
|
||||
d.Subscribe("mojang_textures:textures:after_call", l.createMojangTexturesErrorHandler("textures"))
|
||||
}
|
||||
|
||||
func (l *Logger) handleAfterSkinsystemRequest(req *http.Request, statusCode int) {
|
||||
@ -41,51 +33,6 @@ func (l *Logger) handleAfterSkinsystemRequest(req *http.Request, statusCode int)
|
||||
)
|
||||
}
|
||||
|
||||
func (l *Logger) createMojangTexturesErrorHandler(provider string) func(identity string, result interface{}, err error) {
|
||||
providerParam := wd.NameParam(provider)
|
||||
return func(identity string, result interface{}, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
errParam := wd.ErrParam(err)
|
||||
|
||||
switch err.(type) {
|
||||
case *mojang.BadRequestError:
|
||||
l.logMojangTexturesWarning(providerParam, errParam)
|
||||
return
|
||||
case *mojang.ForbiddenError:
|
||||
l.logMojangTexturesWarning(providerParam, errParam)
|
||||
return
|
||||
case *mojang.TooManyRequestsError:
|
||||
l.logMojangTexturesWarning(providerParam, errParam)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
l.Error(":name: Unexpected Mojang response error: :err", providerParam, errParam)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) logMojangTexturesWarning(providerParam slf.Param, errParam slf.Param) {
|
||||
l.Warning(":name: :err", providerParam, errParam)
|
||||
}
|
||||
|
||||
func trimPort(ip string) string {
|
||||
// Don't care about possible -1 result because RemoteAddr will always contain ip and port
|
||||
cutTo := strings.LastIndexByte(ip, ':')
|
||||
|
@ -1,18 +1,14 @@
|
||||
package eventsubscribers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/mono83/slf"
|
||||
"github.com/mono83/slf/params"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/dispatcher"
|
||||
)
|
||||
|
||||
@ -130,99 +126,6 @@ var loggerTestCases = map[string]*LoggerTestCase{
|
||||
},
|
||||
}
|
||||
|
||||
type timeoutError struct{}
|
||||
|
||||
func (*timeoutError) Error() string { return "timeout error" }
|
||||
func (*timeoutError) Timeout() bool { return true }
|
||||
func (*timeoutError) Temporary() bool { return false }
|
||||
|
||||
func init() {
|
||||
// mojang_textures providers errors
|
||||
for _, providerName := range []string{"usernames", "textures"} {
|
||||
pn := providerName // Store pointer to iteration value
|
||||
loggerTestCases["should not log when no error occurred for "+pn+" provider"] = &LoggerTestCase{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:" + pn + ":after_call", pn, &mojang.ProfileInfo{}, nil},
|
||||
},
|
||||
ExpectedCalls: nil,
|
||||
}
|
||||
|
||||
loggerTestCases["should not log when some network errors occured for "+pn+" provider"] = &LoggerTestCase{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &timeoutError{}},
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &url.Error{Op: "GET", URL: "http://localhost"}},
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "read"}},
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "dial"}},
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, syscall.ECONNREFUSED},
|
||||
},
|
||||
ExpectedCalls: nil,
|
||||
}
|
||||
|
||||
loggerTestCases["should log expected mojang errors for "+pn+" provider"] = &LoggerTestCase{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.BadRequestError{
|
||||
ErrorType: "IllegalArgumentException",
|
||||
Message: "profileName can not be null or empty.",
|
||||
}},
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ForbiddenError{}},
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.TooManyRequestsError{}},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"Warning",
|
||||
":name: :err",
|
||||
mock.MatchedBy(func(strParam params.String) bool {
|
||||
return strParam.Key == "name" && strParam.Value == pn
|
||||
}),
|
||||
mock.MatchedBy(func(errParam params.Error) bool {
|
||||
if errParam.Key != "err" {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := errParam.Value.(*mojang.BadRequestError); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := errParam.Value.(*mojang.ForbiddenError); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := errParam.Value.(*mojang.TooManyRequestsError); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
loggerTestCases["should call error when unexpected error occurred for "+pn+" provider"] = &LoggerTestCase{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ServerError{Status: 500}},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"Error",
|
||||
":name: Unexpected Mojang response error: :err",
|
||||
mock.MatchedBy(func(strParam params.String) bool {
|
||||
return strParam.Key == "name" && strParam.Value == pn
|
||||
}),
|
||||
mock.MatchedBy(func(errParam params.Error) bool {
|
||||
if errParam.Key != "err" {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := errParam.Value.(*mojang.ServerError); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger(t *testing.T) {
|
||||
for name, c := range loggerTestCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
@ -7,8 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mono83/slf"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
type StatsReporter struct {
|
||||
@ -42,78 +40,6 @@ func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
|
||||
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.success"))
|
||||
d.Subscribe("authentication:error", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
|
||||
d.Subscribe("authentication:error", s.incCounterHandler("authentication.failed"))
|
||||
|
||||
// Mojang signed textures source events
|
||||
d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request"))
|
||||
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, found bool, err error) {
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
s.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
|
||||
} else {
|
||||
s.IncCounter("mojang_textures.usernames.cache_hit", 1)
|
||||
}
|
||||
})
|
||||
d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if textures != nil {
|
||||
s.IncCounter("mojang_textures.textures.cache_hit", 1)
|
||||
}
|
||||
})
|
||||
d.Subscribe("mojang_textures:already_processing", s.incCounterHandler("mojang_textures.already_scheduled"))
|
||||
d.Subscribe("mojang_textures:usernames:after_call", func(username string, profile *mojang.ProfileInfo, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
s.IncCounter("mojang_textures.usernames.uuid_miss", 1)
|
||||
} else {
|
||||
s.IncCounter("mojang_textures.usernames.uuid_hit", 1)
|
||||
}
|
||||
})
|
||||
d.Subscribe("mojang_textures:textures:before_call", s.incCounterHandler("mojang_textures.textures.request"))
|
||||
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if textures == nil {
|
||||
s.IncCounter("mojang_textures.usernames.textures_miss", 1)
|
||||
} else {
|
||||
s.IncCounter("mojang_textures.usernames.textures_hit", 1)
|
||||
}
|
||||
})
|
||||
d.Subscribe("mojang_textures:before_result", func(username string, uuid string) {
|
||||
s.startTimeRecording("mojang_textures_result_time_" + username)
|
||||
})
|
||||
d.Subscribe("mojang_textures:after_result", func(username string, textures *mojang.SignedTexturesResponse, err error) {
|
||||
s.finalizeTimeRecording("mojang_textures_result_time_"+username, "mojang_textures.result_time")
|
||||
})
|
||||
d.Subscribe("mojang_textures:textures:before_call", func(uuid string) {
|
||||
s.startTimeRecording("mojang_textures_provider_time_" + uuid)
|
||||
})
|
||||
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
|
||||
s.finalizeTimeRecording("mojang_textures_provider_time_"+uuid, "mojang_textures.textures.request_time")
|
||||
})
|
||||
|
||||
// Mojang UUIDs batch provider metrics
|
||||
d.Subscribe("mojang_textures:batch_uuids_provider:queued", s.incCounterHandler("mojang_textures.usernames.queued"))
|
||||
d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) {
|
||||
s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames)))
|
||||
s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize))
|
||||
if len(usernames) != 0 {
|
||||
s.startTimeRecording("batch_uuids_provider_round_time_" + strings.Join(usernames, "|"))
|
||||
}
|
||||
})
|
||||
d.Subscribe("mojang_textures:batch_uuids_provider:result", func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
|
||||
s.finalizeTimeRecording("batch_uuids_provider_round_time_"+strings.Join(usernames, "|"), "mojang_textures.usernames.round_time")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StatsReporter) handleBeforeRequest(req *http.Request) {
|
||||
|
@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/mono83/slf"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/dispatcher"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -210,167 +209,6 @@ var statsReporterTestCases = []*StatsReporterTestCase{
|
||||
{"IncCounter", "authentication.failed", int64(1)},
|
||||
},
|
||||
},
|
||||
// Mojang signed textures provider
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:call", "username"},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.request", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_cache", "username", "", false, errors.New("error")},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_cache", "username", "", false, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_cache", "username", "", true, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.cache_hit", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.textures.cache_hit", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:already_processing", "username"},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.already_scheduled", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_call", "username", nil, errors.New("error")},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_call", "username", nil, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:usernames:after_call", "username", &mojang.ProfileInfo{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.textures_miss", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:before_result", "username", ""},
|
||||
{"mojang_textures:after_result", "username", &mojang.SignedTexturesResponse{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"RecordTimer", "mojang_textures.result_time", mock.AnythingOfType("time.Duration")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:textures:before_call", "аааааааааааааааааааааааааааааааа"},
|
||||
{"mojang_textures:textures:after_call", "аааааааааааааааааааааааааааааааа", &mojang.SignedTexturesResponse{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.textures.request", int64(1)},
|
||||
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
|
||||
{"RecordTimer", "mojang_textures.textures.request_time", mock.AnythingOfType("time.Duration")},
|
||||
},
|
||||
},
|
||||
// Batch UUIDs provider
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:batch_uuids_provider:queued", "username"},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"IncCounter", "mojang_textures.usernames.queued", int64(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 5},
|
||||
{"mojang_textures:batch_uuids_provider:result", []string{"username1", "username2"}, []*mojang.ProfileInfo{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
|
||||
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
|
||||
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Events: [][]interface{}{
|
||||
{"mojang_textures:batch_uuids_provider:round", []string{}, 0},
|
||||
// This event will be not emitted, but we emit it to ensure, that RecordTimer will not be called
|
||||
{"mojang_textures:batch_uuids_provider:result", []string{}, []*mojang.ProfileInfo{}, nil},
|
||||
},
|
||||
ExpectedCalls: [][]interface{}{
|
||||
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(0)},
|
||||
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)},
|
||||
// Should not call RecordTimer
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestStatsReporter(t *testing.T) {
|
||||
|
3
go.mod
3
go.mod
@ -8,10 +8,12 @@ replace github.com/asaskevich/EventBus v0.0.0-20200330115301-33b3bc6a7ddc => git
|
||||
require (
|
||||
github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2
|
||||
github.com/asaskevich/EventBus v0.0.0-20200330115301-33b3bc6a7ddc
|
||||
github.com/brunomvsouza/singleflight v0.4.0
|
||||
github.com/defval/di v1.12.0
|
||||
github.com/etherlabsio/healthcheck/v2 v2.0.0
|
||||
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/jellydator/ttlcache/v3 v3.1.1
|
||||
github.com/mediocregopher/radix/v4 v4.1.4
|
||||
github.com/mono83/slf v0.0.0-20170919161409-79153e9636db
|
||||
github.com/spf13/cobra v1.8.0
|
||||
@ -48,6 +50,7 @@ require (
|
||||
github.com/tilinna/clock v1.0.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect
|
||||
golang.org/x/sync v0.5.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
12
go.sum
12
go.sum
@ -1,5 +1,7 @@
|
||||
github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2 h1:koK7z0nSsRiRiBWwa+E714Puh+DO+ZRdIyAXiXzL+lg=
|
||||
github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA=
|
||||
github.com/brunomvsouza/singleflight v0.4.0 h1:9dNcTeYoXSus3xbZEM0EEZ11EcCRjUZOvVW8rnDMG5Y=
|
||||
github.com/brunomvsouza/singleflight v0.4.0/go.mod h1:8RYo9j5WQRupmsnUz5DlUWZxDLNi+t9Zhj3EZFmns7I=
|
||||
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s=
|
||||
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
@ -21,6 +23,8 @@ github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea h1:t6e33/eet/
|
||||
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/goradd/maps v0.1.5 h1:Ut7BPJgNy5BYbleI3LswVJJquiM8X5uN0ZuZBHSdRUI=
|
||||
github.com/goradd/maps v0.1.5/go.mod h1:E5X1CHMgfVm1qFTHgXpgVLVylO5wtlhZdB93dRGjnc0=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||
@ -31,6 +35,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.1 h1:RCgYJqo3jgvhl+fEWvjNW8thxGWsgxi+TPhRir1Y9y8=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.1/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@ -59,6 +65,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM=
|
||||
github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
@ -87,10 +95,14 @@ github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOj
|
||||
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
|
||||
github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI=
|
||||
github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao=
|
||||
github.com/zyedidia/generic v1.2.1 h1:Zv5KS/N2m0XZZiuLS82qheRG4X1o5gsWreGb0hR7XDc=
|
||||
github.com/zyedidia/generic v1.2.1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
|
||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/thedevsaddam/govalidator"
|
||||
@ -13,10 +14,7 @@ import (
|
||||
"github.com/elyby/chrly/model"
|
||||
)
|
||||
|
||||
// noinspection GoSnakeCaseUsage
|
||||
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
|
||||
var regexUuidAny = regexp.MustCompile(UUID_ANY)
|
||||
var regexUuidAny = regexp.MustCompile("(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
|
||||
func init() {
|
||||
// Add ability to validate any possible uuid form
|
||||
@ -73,7 +71,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
|
||||
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
|
||||
|
||||
record.Uuid = req.Form.Get("uuid")
|
||||
record.Uuid = strings.ToLower(req.Form.Get("uuid"))
|
||||
record.SkinId = skinId
|
||||
record.Is1_8 = is18
|
||||
record.IsSlim = isSlim
|
||||
|
@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@ -27,7 +28,7 @@ func StartServer(server *http.Server, logger slf.Logger) {
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
|
||||
close(done)
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -13,8 +14,8 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/model"
|
||||
"github.com/elyby/chrly/mojang"
|
||||
"github.com/elyby/chrly/utils"
|
||||
)
|
||||
|
||||
@ -307,13 +308,17 @@ func (ctx *Skinsystem) getProfile(request *http.Request, proxy bool) (*profile,
|
||||
profile.MojangSignature = skin.MojangSignature
|
||||
} else if proxy {
|
||||
mojangProfile, err := ctx.MojangTexturesProvider.GetForUsername(username)
|
||||
// If we at least know something about a user,
|
||||
// than we can ignore an error and return profile without textures
|
||||
// If we at least know something about the user,
|
||||
// then we can ignore an error and return profile without textures
|
||||
if err != nil && profile.Id != "" {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
if err != nil || mojangProfile == nil {
|
||||
if errors.Is(err, mojang.InvalidUsername) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -19,8 +19,8 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/model"
|
||||
"github.com/elyby/chrly/mojang"
|
||||
)
|
||||
|
||||
/***************
|
||||
|
114
mojang/batch_uuids_provider.go
Normal file
114
mojang/batch_uuids_provider.go
Normal file
@ -0,0 +1,114 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/elyby/chrly/utils"
|
||||
)
|
||||
|
||||
type BatchUuidsProvider struct {
|
||||
UsernamesToUuidsEndpoint func(usernames []string) ([]*ProfileInfo, error)
|
||||
batch int
|
||||
delay time.Duration
|
||||
fireOnFull bool
|
||||
|
||||
queue *utils.Queue[*job]
|
||||
fireChan chan any
|
||||
stopChan chan any
|
||||
onFirstCall sync.Once
|
||||
}
|
||||
|
||||
func NewBatchUuidsProvider(
|
||||
endpoint func(usernames []string) ([]*ProfileInfo, error),
|
||||
batchSize int,
|
||||
awaitDelay time.Duration,
|
||||
fireOnFull bool,
|
||||
) *BatchUuidsProvider {
|
||||
return &BatchUuidsProvider{
|
||||
UsernamesToUuidsEndpoint: endpoint,
|
||||
stopChan: make(chan any),
|
||||
batch: batchSize,
|
||||
delay: awaitDelay,
|
||||
fireOnFull: fireOnFull,
|
||||
queue: utils.NewQueue[*job](),
|
||||
fireChan: make(chan any),
|
||||
}
|
||||
}
|
||||
|
||||
type job struct {
|
||||
Username string
|
||||
ResultChan chan<- *jobResult
|
||||
}
|
||||
|
||||
type jobResult struct {
|
||||
Profile *ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) GetUuid(username string) (*ProfileInfo, error) {
|
||||
resultChan := make(chan *jobResult)
|
||||
n := ctx.queue.Enqueue(&job{username, resultChan})
|
||||
if ctx.fireOnFull && n%ctx.batch == 0 {
|
||||
ctx.fireChan <- struct{}{}
|
||||
}
|
||||
|
||||
ctx.onFirstCall.Do(ctx.startQueue)
|
||||
|
||||
result := <-resultChan
|
||||
|
||||
return result.Profile, result.Error
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) StopQueue() {
|
||||
close(ctx.stopChan)
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) startQueue() {
|
||||
go func() {
|
||||
for {
|
||||
t := time.NewTimer(ctx.delay)
|
||||
select {
|
||||
case <-ctx.stopChan:
|
||||
return
|
||||
case <-t.C:
|
||||
go ctx.fireRequest()
|
||||
case <-ctx.fireChan:
|
||||
t.Stop()
|
||||
go ctx.fireRequest()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) fireRequest() {
|
||||
jobs, _ := ctx.queue.Dequeue(ctx.batch)
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(jobs))
|
||||
for i, job := range jobs {
|
||||
usernames[i] = job.Username
|
||||
}
|
||||
|
||||
profiles, err := ctx.UsernamesToUuidsEndpoint(usernames)
|
||||
for _, job := range jobs {
|
||||
response := &jobResult{}
|
||||
if err == nil {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.Error = err
|
||||
}
|
||||
|
||||
job.ResultChan <- response
|
||||
close(job.ResultChan)
|
||||
}
|
||||
}
|
173
mojang/batch_uuids_provider_test.go
Normal file
173
mojang/batch_uuids_provider_test.go
Normal file
@ -0,0 +1,173 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var awaitDelay = 20 * time.Millisecond
|
||||
|
||||
type mojangUsernamesToUuidsRequestMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
|
||||
args := o.Called(usernames)
|
||||
var result []*ProfileInfo
|
||||
if casted, ok := args.Get(0).([]*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type batchUuidsProviderGetUuidResult struct {
|
||||
Result *ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
type batchUuidsProviderTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *BatchUuidsProvider
|
||||
|
||||
MojangApi *mojangUsernamesToUuidsRequestMock
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) SetupTest() {
|
||||
s.MojangApi = &mojangUsernamesToUuidsRequestMock{}
|
||||
s.Provider = NewBatchUuidsProvider(
|
||||
s.MojangApi.UsernamesToUuids,
|
||||
3,
|
||||
awaitDelay,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TearDownTest() {
|
||||
s.MojangApi.AssertExpectations(s.T())
|
||||
s.Provider.StopQueue()
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
|
||||
startedChan := make(chan any)
|
||||
c := make(chan *batchUuidsProviderGetUuidResult, 1)
|
||||
go func() {
|
||||
close(startedChan)
|
||||
profile, err := s.Provider.GetUuid(username)
|
||||
c <- &batchUuidsProviderGetUuidResult{
|
||||
Result: profile,
|
||||
Error: err,
|
||||
}
|
||||
close(c)
|
||||
}()
|
||||
|
||||
<-startedChan
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesSuccessfully() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedResult1 := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
|
||||
expectedResult2 := &ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*ProfileInfo{
|
||||
expectedResult1,
|
||||
expectedResult2,
|
||||
}, nil)
|
||||
|
||||
chan1 := s.GetUuidAsync("username1")
|
||||
chan2 := s.GetUuidAsync("username2")
|
||||
|
||||
s.Require().Empty(chan1)
|
||||
s.Require().Empty(chan2)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
result1 := <-chan1
|
||||
result2 := <-chan2
|
||||
|
||||
s.Require().NoError(result1.Error)
|
||||
s.Require().Equal(expectedResult1, result1.Result)
|
||||
|
||||
s.Require().NoError(result2.Error)
|
||||
s.Require().Equal(expectedResult2, result2.Result)
|
||||
|
||||
// Await a few more iterations to ensure, that no requests will be performed when there are no additional tasks
|
||||
time.Sleep(awaitDelay * 3)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesSplitByMultipleIterations() {
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsync("username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan3)
|
||||
s.Require().Empty(resultChan4)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesFireOnFull() {
|
||||
s.Provider.fireOnFull = true
|
||||
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsync("username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 0.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan3)
|
||||
s.Require().Empty(resultChan4)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedError := errors.New("mock error")
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
|
||||
result1 := <-resultChan1
|
||||
s.Assert().Nil(result1.Result)
|
||||
s.Assert().Equal(expectedError, result1.Error)
|
||||
|
||||
result2 := <-resultChan2
|
||||
s.Assert().Nil(result2.Result)
|
||||
s.Assert().Equal(expectedError, result2.Error)
|
||||
}
|
||||
|
||||
func TestBatchUuidsProvider(t *testing.T) {
|
||||
suite.Run(t, new(batchUuidsProviderTestSuite))
|
||||
}
|
@ -2,20 +2,114 @@ package mojang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var HttpClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: 1024,
|
||||
},
|
||||
type MojangApi struct {
|
||||
http *http.Client
|
||||
batchUuidsUrl string
|
||||
profileUrl string
|
||||
}
|
||||
|
||||
func NewMojangApi(
|
||||
http *http.Client,
|
||||
batchUuidsUrl string,
|
||||
profileUrl string,
|
||||
) *MojangApi {
|
||||
if batchUuidsUrl == "" {
|
||||
batchUuidsUrl = "https://api.mojang.com/profiles/minecraft"
|
||||
}
|
||||
|
||||
if profileUrl == "" {
|
||||
profileUrl = "https://sessionserver.mojang.com/session/minecraft/profile/"
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(profileUrl, "/") {
|
||||
profileUrl += "/"
|
||||
}
|
||||
|
||||
return &MojangApi{
|
||||
http,
|
||||
batchUuidsUrl,
|
||||
profileUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Exchanges usernames array to array of uuids
|
||||
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
|
||||
func (c *MojangApi) UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
|
||||
requestBody, _ := json.Marshal(usernames)
|
||||
request, err := http.NewRequest("POST", c.batchUuidsUrl, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errorFromResponse(response)
|
||||
}
|
||||
|
||||
var result []*ProfileInfo
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Obtains textures information for provided uuid
|
||||
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
|
||||
func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
|
||||
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
|
||||
url := c.profileUrl + normalizedUuid
|
||||
if signed {
|
||||
url += "?unsigned=false"
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode == 204 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errorFromResponse(response)
|
||||
}
|
||||
|
||||
var result *SignedTexturesResponse
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type SignedTexturesResponse struct {
|
||||
@ -28,6 +122,31 @@ type SignedTexturesResponse struct {
|
||||
decodedErr error
|
||||
}
|
||||
|
||||
type TexturesProp struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Textures *TexturesResponse `json:"textures"`
|
||||
}
|
||||
|
||||
type TexturesResponse struct {
|
||||
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
|
||||
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CapeTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) {
|
||||
t.once.Do(func() {
|
||||
var texturesProp string
|
||||
@ -66,74 +185,8 @@ type ProfileInfo struct {
|
||||
IsDemo bool `json:"demo,omitempty"`
|
||||
}
|
||||
|
||||
var ApiMojangDotComAddr = "https://api.mojang.com"
|
||||
var SessionServerMojangComAddr = "https://sessionserver.mojang.com"
|
||||
|
||||
// Exchanges usernames array to array of uuids
|
||||
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
|
||||
func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
|
||||
requestBody, _ := json.Marshal(usernames)
|
||||
request, err := http.NewRequest("POST", ApiMojangDotComAddr+"/profiles/minecraft", bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := HttpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if responseErr := validateResponse(response); responseErr != nil {
|
||||
return nil, responseErr
|
||||
}
|
||||
|
||||
var result []*ProfileInfo
|
||||
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Obtains textures information for provided uuid
|
||||
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
|
||||
func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
|
||||
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
|
||||
url := SessionServerMojangComAddr + "/session/minecraft/profile/" + normalizedUuid
|
||||
if signed {
|
||||
url += "?unsigned=false"
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := HttpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if responseErr := validateResponse(response); responseErr != nil {
|
||||
return nil, responseErr
|
||||
}
|
||||
|
||||
var result *SignedTexturesResponse
|
||||
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateResponse(response *http.Response) error {
|
||||
func errorFromResponse(response *http.Response) error {
|
||||
switch {
|
||||
case response.StatusCode == 204:
|
||||
return &EmptyResponse{}
|
||||
case response.StatusCode == 400:
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
@ -141,7 +194,7 @@ func validateResponse(response *http.Response) error {
|
||||
}
|
||||
|
||||
var decodedError *errorResponse
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &decodedError)
|
||||
|
||||
return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message}
|
||||
@ -153,29 +206,11 @@ func validateResponse(response *http.Response) error {
|
||||
return &ServerError{Status: response.StatusCode}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ResponseError interface {
|
||||
IsMojangError() bool
|
||||
}
|
||||
|
||||
// Mojang API doesn't return a 404 Not Found error for non-existent data identifiers
|
||||
// Instead, they return 204 with an empty body
|
||||
type EmptyResponse struct {
|
||||
}
|
||||
|
||||
func (*EmptyResponse) Error() string {
|
||||
return "204: Empty Response"
|
||||
}
|
||||
|
||||
func (*EmptyResponse) IsMojangError() bool {
|
||||
return true
|
||||
return fmt.Errorf("unexpected response status code: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
// When passed request params are invalid, Mojang returns 400 Bad Request error
|
||||
type BadRequestError struct {
|
||||
ResponseError
|
||||
ErrorType string
|
||||
Message string
|
||||
}
|
||||
@ -184,13 +219,8 @@ func (e *BadRequestError) Error() string {
|
||||
return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
|
||||
}
|
||||
|
||||
func (*BadRequestError) IsMojangError() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization)
|
||||
type ForbiddenError struct {
|
||||
ResponseError
|
||||
}
|
||||
|
||||
func (*ForbiddenError) Error() string {
|
||||
@ -199,20 +229,14 @@ func (*ForbiddenError) Error() string {
|
||||
|
||||
// When you exceed the set limit of requests, this error will be returned
|
||||
type TooManyRequestsError struct {
|
||||
ResponseError
|
||||
}
|
||||
|
||||
func (*TooManyRequestsError) Error() string {
|
||||
return "429: Too Many Requests"
|
||||
}
|
||||
|
||||
func (*TooManyRequestsError) IsMojangError() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ServerError happens when Mojang's API returns any response with 50* status
|
||||
type ServerError struct {
|
||||
ResponseError
|
||||
Status int
|
||||
}
|
||||
|
||||
@ -220,6 +244,22 @@ func (e *ServerError) Error() string {
|
||||
return fmt.Sprintf("%d: %s", e.Status, "Server error")
|
||||
}
|
||||
|
||||
func (*ServerError) IsMojangError() bool {
|
||||
return true
|
||||
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
|
||||
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *TexturesProp
|
||||
err = json.Unmarshal(jsonStr, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EncodeTextures(textures *TexturesProp) string {
|
||||
jsonSerialized, _ := json.Marshal(textures)
|
||||
return base64.URLEncoding.EncodeToString(jsonSerialized)
|
||||
}
|
318
mojang/client_test.go
Normal file
318
mojang/client_test.go
Normal file
@ -0,0 +1,318 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/h2non/gock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MojangApiSuite struct {
|
||||
suite.Suite
|
||||
api *MojangApi
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) SetupTest() {
|
||||
httpClient := &http.Client{}
|
||||
gock.InterceptClient(httpClient)
|
||||
s.api = NewMojangApi(httpClient, "", "")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TearDownTest() {
|
||||
gock.Off()
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsSuccessfully() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
JSON([]string{"Thinkofdeath", "maksimkurb"}).
|
||||
Reply(200).
|
||||
JSON([]map[string]any{
|
||||
{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"legacy": false,
|
||||
"demo": true,
|
||||
},
|
||||
{
|
||||
"id": "0d252b7218b648bfb86c2ae476954d32",
|
||||
"name": "maksimkurb",
|
||||
// There are no legacy or demo fields
|
||||
},
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
if s.Assert().NoError(err) {
|
||||
s.Assert().Len(result, 2)
|
||||
s.Assert().Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
|
||||
s.Assert().Equal("Thinkofdeath", result[0].Name)
|
||||
s.Assert().False(result[0].IsLegacy)
|
||||
s.Assert().True(result[0].IsDemo)
|
||||
|
||||
s.Assert().Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
|
||||
s.Assert().Equal("maksimkurb", result[1].Name)
|
||||
s.Assert().False(result[1].IsLegacy)
|
||||
s.Assert().False(result[1].IsDemo)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsBadRequest() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(400).
|
||||
JSON(map[string]any{
|
||||
"error": "IllegalArgumentException",
|
||||
"errorMessage": "profileName can not be null or empty.",
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{""})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&BadRequestError{}, err)
|
||||
s.Assert().EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsForbidden() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(403).
|
||||
BodyString("just because")
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&ForbiddenError{}, err)
|
||||
s.Assert().EqualError(err, "403: Forbidden")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsTooManyRequests() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(429).
|
||||
JSON(map[string]any{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&TooManyRequestsError{}, err)
|
||||
s.Assert().EqualError(err, "429: Too Many Requests")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsServerError() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&ServerError{}, err)
|
||||
s.Assert().EqualError(err, "500: Server error")
|
||||
s.Assert().Equal(500, err.(*ServerError).Status)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesSuccessfulResponse() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(200).
|
||||
JSON(map[string]any{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []any{
|
||||
map[string]any{
|
||||
"name": "textures",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().NoError(err)
|
||||
s.Assert().Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
s.Assert().Equal("Thinkofdeath", result.Name)
|
||||
s.Assert().Equal(1, len(result.Props))
|
||||
s.Assert().Equal("textures", result.Props[0].Name)
|
||||
s.Assert().Equal(476, len(result.Props[0].Value))
|
||||
s.Assert().Equal("", result.Props[0].Signature)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesEmptyResponse() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(204).
|
||||
BodyString("")
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().NoError(err)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesTooManyRequests() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(429).
|
||||
JSON(map[string]any{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&TooManyRequestsError{}, err)
|
||||
s.Assert().EqualError(err, "429: Too Many Requests")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesServerError() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&ServerError{}, err)
|
||||
s.Assert().EqualError(err, "500: Server error")
|
||||
s.Assert().Equal(500, err.(*ServerError).Status)
|
||||
}
|
||||
|
||||
func TestMojangApi(t *testing.T) {
|
||||
suite.Run(t, new(MojangApiSuite))
|
||||
}
|
||||
|
||||
func TestSignedTexturesResponse(t *testing.T) {
|
||||
t.Run("DecodeTextures", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
},
|
||||
},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
|
||||
})
|
||||
|
||||
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Nil(t, textures)
|
||||
})
|
||||
}
|
||||
|
||||
type texturesTestCase struct {
|
||||
Name string
|
||||
Encoded string
|
||||
Decoded *TexturesProp
|
||||
}
|
||||
|
||||
var texturesTestCases = []*texturesTestCase{
|
||||
{
|
||||
Name: "property without textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856010494),
|
||||
Textures: &TexturesResponse{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with classic skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856307412),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with alex skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856494791),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
|
||||
Metadata: &SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with skin and cape textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "d90b68bc81724329a047f1186dcd4336",
|
||||
ProfileName: "akronman1",
|
||||
Timestamp: int64(1555857675335),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
|
||||
},
|
||||
Cape: &CapeTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestDecodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("decode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures(testCase.Encoded)
|
||||
assert.Nil(err)
|
||||
assert.Equal(testCase.Decoded, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("invalid base64")
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
|
||||
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("encode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result := EncodeTextures(testCase.Decoded)
|
||||
assert.Equal(testCase.Encoded, result)
|
||||
})
|
||||
}
|
||||
}
|
59
mojang/provider.go
Normal file
59
mojang/provider.go
Normal file
@ -0,0 +1,59 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/brunomvsouza/singleflight"
|
||||
)
|
||||
|
||||
var InvalidUsername = errors.New("the username passed doesn't meet Mojang's requirements")
|
||||
|
||||
// https://help.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV
|
||||
var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{3,16}$`)
|
||||
|
||||
type UuidsProvider interface {
|
||||
GetUuid(username string) (*ProfileInfo, error)
|
||||
}
|
||||
|
||||
type TexturesProvider interface {
|
||||
GetTextures(uuid string) (*SignedTexturesResponse, error)
|
||||
}
|
||||
|
||||
type MojangTexturesProvider struct {
|
||||
UuidsProvider
|
||||
TexturesProvider
|
||||
|
||||
group singleflight.Group[string, *SignedTexturesResponse]
|
||||
}
|
||||
|
||||
func (p *MojangTexturesProvider) GetForUsername(username string) (*SignedTexturesResponse, error) {
|
||||
if !allowedUsernamesRegex.MatchString(username) {
|
||||
return nil, InvalidUsername
|
||||
}
|
||||
|
||||
username = strings.ToLower(username)
|
||||
|
||||
result, err, _ := p.group.Do(username, func() (*SignedTexturesResponse, error) {
|
||||
profile, err := p.UuidsProvider.GetUuid(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return p.TexturesProvider.GetTextures(profile.Id)
|
||||
})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
type NilProvider struct {
|
||||
}
|
||||
|
||||
func (*NilProvider) GetForUsername(username string) (*SignedTexturesResponse, error) {
|
||||
return nil, nil
|
||||
}
|
167
mojang/provider_test.go
Normal file
167
mojang/provider_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type mockUuidsProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockUuidsProvider) GetUuid(username string) (*ProfileInfo, error) {
|
||||
args := m.Called(username)
|
||||
var result *ProfileInfo
|
||||
if casted, ok := args.Get(0).(*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type TexturesProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *TexturesProviderMock) GetTextures(uuid string) (*SignedTexturesResponse, error) {
|
||||
args := m.Called(uuid)
|
||||
var result *SignedTexturesResponse
|
||||
if casted, ok := args.Get(0).(*SignedTexturesResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type providerTestSuite struct {
|
||||
suite.Suite
|
||||
Provider *MojangTexturesProvider
|
||||
UuidsProvider *mockUuidsProvider
|
||||
TexturesProvider *TexturesProviderMock
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) SetupTest() {
|
||||
suite.UuidsProvider = &mockUuidsProvider{}
|
||||
suite.TexturesProvider = &TexturesProviderMock{}
|
||||
|
||||
suite.Provider = &MojangTexturesProvider{
|
||||
UuidsProvider: suite.UuidsProvider,
|
||||
TexturesProvider: suite.TexturesProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TearDownTest() {
|
||||
suite.UuidsProvider.AssertExpectations(suite.T())
|
||||
suite.TexturesProvider.AssertExpectations(suite.T())
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForValidUsernameSuccessfully() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().NoError(err)
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
|
||||
// TODO: check that textures provider wasn't called
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().NoError(err)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().NoError(err)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForTheSameUsername() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
awaitChan := make(chan time.Time)
|
||||
|
||||
// If possible, then remove this .After call
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().WaitUntil(awaitChan).Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
|
||||
|
||||
results := make([]*SignedTexturesResponse, 2)
|
||||
var wgStarted sync.WaitGroup
|
||||
var wgDone sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wgStarted.Add(1)
|
||||
wgDone.Add(1)
|
||||
go func(i int) {
|
||||
wgStarted.Done()
|
||||
textures, _ := suite.Provider.GetForUsername("username")
|
||||
results[i] = textures
|
||||
wgDone.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wgStarted.Wait()
|
||||
close(awaitChan)
|
||||
wgDone.Wait()
|
||||
|
||||
suite.Assert().Equal(expectedResult, results[0])
|
||||
suite.Assert().Equal(expectedResult, results[1])
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
|
||||
result, err := suite.Provider.GetForUsername("Not allowed")
|
||||
suite.Assert().ErrorIs(err, InvalidUsername)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
|
||||
err := errors.New("mock error")
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
|
||||
|
||||
result, resErr := suite.Provider.GetForUsername("username")
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(err, resErr)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
err := errors.New("mock error")
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
|
||||
|
||||
result, resErr := suite.Provider.GetForUsername("username")
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(err, resErr)
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
suite.Run(t, new(providerTestSuite))
|
||||
}
|
||||
|
||||
func TestNilProvider_GetForUsername(t *testing.T) {
|
||||
provider := &NilProvider{}
|
||||
result, err := provider.GetForUsername("username")
|
||||
require.Nil(t, result)
|
||||
require.NoError(t, err)
|
||||
}
|
67
mojang/textures_provider.go
Normal file
67
mojang/textures_provider.go
Normal file
@ -0,0 +1,67 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
)
|
||||
|
||||
type MojangApiTexturesProvider struct {
|
||||
MojangApiTexturesEndpoint func(uuid string, signed bool) (*SignedTexturesResponse, error)
|
||||
}
|
||||
|
||||
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*SignedTexturesResponse, error) {
|
||||
return ctx.MojangApiTexturesEndpoint(uuid, true)
|
||||
}
|
||||
|
||||
// Perfectly there should be an object with provider and cache implementation,
|
||||
// but I decided not to introduce a layer and just implement cache in place.
|
||||
type TexturesProviderWithInMemoryCache struct {
|
||||
provider TexturesProvider
|
||||
once sync.Once
|
||||
cache *ttlcache.Cache[string, *SignedTexturesResponse]
|
||||
}
|
||||
|
||||
func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesProviderWithInMemoryCache {
|
||||
storage := &TexturesProviderWithInMemoryCache{
|
||||
provider: provider,
|
||||
cache: ttlcache.New[string, *SignedTexturesResponse](
|
||||
ttlcache.WithDisableTouchOnHit[string, *SignedTexturesResponse](),
|
||||
// I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error
|
||||
),
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*SignedTexturesResponse, error) {
|
||||
item := s.cache.Get(uuid)
|
||||
// Don't check item.IsExpired() since Get function is already did this check
|
||||
if item != nil {
|
||||
return item.Value(), nil
|
||||
}
|
||||
|
||||
result, err := s.provider.GetTextures(uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache.Set(uuid, result, time.Minute)
|
||||
// Call it only after first set so GC will work more often
|
||||
s.startGcOnce()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) StopGC() {
|
||||
// If you call the Stop() on a non-started GC, the process will hang trying to close the uninitialized channel
|
||||
s.startGcOnce()
|
||||
s.cache.Stop()
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) startGcOnce() {
|
||||
s.once.Do(func() {
|
||||
go s.cache.Start()
|
||||
})
|
||||
}
|
139
mojang/textures_provider_test.go
Normal file
139
mojang/textures_provider_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var signedTexturesResponse = &SignedTexturesResponse{
|
||||
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: EncodeTextures(&TexturesProp{
|
||||
Timestamp: time.Now().UnixNano() / 10e5,
|
||||
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
ProfileName: "mock",
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type MojangUuidToTexturesRequestMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
|
||||
args := m.Called(uuid, signed)
|
||||
var result *SignedTexturesResponse
|
||||
if casted, ok := args.Get(0).(*SignedTexturesResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type MojangApiTexturesProviderSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *MojangApiTexturesProvider
|
||||
MojangApi *MojangUuidToTexturesRequestMock
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) SetupTest() {
|
||||
s.MojangApi = &MojangUuidToTexturesRequestMock{}
|
||||
s.Provider = &MojangApiTexturesProvider{
|
||||
MojangApiTexturesEndpoint: s.MojangApi.UuidToTextures,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TearDownTest() {
|
||||
s.MojangApi.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TestGetTextures() {
|
||||
s.MojangApi.On("UuidToTextures", "dead24f9a4fa4877b7b04c8c6c72bb46", true).Once().Return(signedTexturesResponse, nil)
|
||||
|
||||
result, err := s.Provider.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(signedTexturesResponse, result)
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TestGetTexturesWithError() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
|
||||
|
||||
result, err := s.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
|
||||
s.Require().Nil(result)
|
||||
s.Require().Equal(expectedError, err)
|
||||
}
|
||||
|
||||
func TestMojangApiTexturesProvider(t *testing.T) {
|
||||
suite.Run(t, new(MojangApiTexturesProviderSuite))
|
||||
}
|
||||
|
||||
type TexturesProviderWithInMemoryCacheSuite struct {
|
||||
suite.Suite
|
||||
Original *TexturesProviderMock
|
||||
Provider *TexturesProviderWithInMemoryCache
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) SetupTest() {
|
||||
s.Original = &TexturesProviderMock{}
|
||||
s.Provider = NewTexturesProviderWithInMemoryCache(s.Original)
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TearDownTest() {
|
||||
s.Original.AssertExpectations(s.T())
|
||||
s.Provider.StopGC()
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithSuccessfulOriginalProviderResponse() {
|
||||
s.Original.On("GetTextures", "uuid").Once().Return(signedTexturesResponse, nil)
|
||||
// Do the call multiple times to ensure, that there will be only one call to the Original provider
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures("uuid")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(signedTexturesResponse, result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithEmptyOriginalProviderResponse() {
|
||||
s.Original.On("GetTextures", "uuid").Once().Return(nil, nil)
|
||||
// Do the call multiple times to ensure, that there will be only one call to the original provider
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures("uuid")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithErrorFromOriginalProvider() {
|
||||
expectedErr := errors.New("mock error")
|
||||
s.Original.On("GetTextures", "uuid").Times(5).Return(nil, expectedErr)
|
||||
// Do the call multiple times to ensure, that the error will not be cached and there will be a request on each call
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures("uuid")
|
||||
|
||||
s.Require().Same(expectedErr, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTexturesProviderWithInMemoryCache(t *testing.T) {
|
||||
suite.Run(t, new(TexturesProviderWithInMemoryCacheSuite))
|
||||
}
|
45
mojang/uuids_provider.go
Normal file
45
mojang/uuids_provider.go
Normal file
@ -0,0 +1,45 @@
|
||||
package mojang
|
||||
|
||||
type MojangUuidsStorage interface {
|
||||
// The second argument must be returned as a incoming username in case,
|
||||
// when cached result indicates that there is no Mojang user with provided username
|
||||
GetUuidForMojangUsername(username string) (foundUuid string, foundUsername string, err error)
|
||||
// An empty uuid value can be passed if the corresponding account has not been found
|
||||
StoreMojangUuid(username string, uuid string) error
|
||||
}
|
||||
|
||||
type UuidsProviderWithCache struct {
|
||||
Provider UuidsProvider
|
||||
Storage MojangUuidsStorage
|
||||
}
|
||||
|
||||
func (p *UuidsProviderWithCache) GetUuid(username string) (*ProfileInfo, error) {
|
||||
uuid, foundUsername, err := p.Storage.GetUuidForMojangUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if foundUsername != "" {
|
||||
if uuid != "" {
|
||||
return &ProfileInfo{Id: uuid, Name: foundUsername}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profile, err := p.Provider.GetUuid(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
freshUuid := ""
|
||||
wellCasedUsername := username
|
||||
if profile != nil {
|
||||
freshUuid = profile.Id
|
||||
wellCasedUsername = profile.Name
|
||||
}
|
||||
|
||||
_ = p.Storage.StoreMojangUuid(wellCasedUsername, freshUuid)
|
||||
|
||||
return profile, nil
|
||||
}
|
131
mojang/uuids_provider_test.go
Normal file
131
mojang/uuids_provider_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var mockProfile = &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "UserName"}
|
||||
|
||||
type UuidsProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *UuidsProviderMock) GetUuid(username string) (*ProfileInfo, error) {
|
||||
args := m.Called(username)
|
||||
var result *ProfileInfo
|
||||
if casted, ok := args.Get(0).(*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type MojangUuidsStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangUuidsStorageMock) GetUuidForMojangUsername(username string) (string, string, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.String(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MojangUuidsStorageMock) StoreMojangUuid(username string, uuid string) error {
|
||||
m.Called(username, uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
type UuidsProviderWithCacheSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Original *UuidsProviderMock
|
||||
Storage *MojangUuidsStorageMock
|
||||
Provider *UuidsProviderWithCache
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) SetupTest() {
|
||||
s.Original = &UuidsProviderMock{}
|
||||
s.Storage = &MojangUuidsStorageMock{}
|
||||
s.Provider = &UuidsProviderWithCache{
|
||||
Provider: s.Original,
|
||||
Storage: s.Storage,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TearDownTest() {
|
||||
s.Original.AssertExpectations(s.T())
|
||||
s.Storage.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUncachedSuccessfully() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", nil)
|
||||
s.Storage.On("StoreMojangUuid", "UserName", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
|
||||
|
||||
s.Original.On("GetUuid", "username").Once().Return(mockProfile, nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(mockProfile, result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUncachedNotExistsMojangUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", nil)
|
||||
s.Storage.On("StoreMojangUuid", "username", "").Once().Return(nil)
|
||||
|
||||
s.Original.On("GetUuid", "username").Once().Return(nil, nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestKnownCachedUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("mock-uuid", "UserName", nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(result)
|
||||
s.Require().Equal("UserName", result.Name)
|
||||
s.Require().Equal("mock-uuid", result.Id)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUnknownCachedUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "UserName", nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestErrorDuringCacheQuery() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", expectedError)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().Same(expectedError, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestErrorFromOriginalProvider() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", nil)
|
||||
|
||||
s.Original.On("GetUuid", "username").Once().Return(nil, expectedError)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().Same(expectedError, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func TestUuidsProviderWithCache(t *testing.T) {
|
||||
suite.Run(t, new(UuidsProviderWithCacheSuite))
|
||||
}
|
@ -1,249 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
type jobResult struct {
|
||||
Profile *mojang.ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
type job struct {
|
||||
Username string
|
||||
RespondChan chan *jobResult
|
||||
}
|
||||
|
||||
type jobsQueue struct {
|
||||
lock sync.Mutex
|
||||
items []*job
|
||||
}
|
||||
|
||||
func newJobsQueue() *jobsQueue {
|
||||
return &jobsQueue{
|
||||
items: []*job{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *jobsQueue) Enqueue(job *job) int {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.items = append(s.items, job)
|
||||
|
||||
return len(s.items)
|
||||
}
|
||||
|
||||
func (s *jobsQueue) Dequeue(n int) ([]*job, int) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
l := len(s.items)
|
||||
if n > l {
|
||||
n = l
|
||||
}
|
||||
|
||||
items := s.items[0:n]
|
||||
s.items = s.items[n:l]
|
||||
|
||||
return items, l - n
|
||||
}
|
||||
|
||||
var usernamesToUuids = mojang.UsernamesToUuids
|
||||
|
||||
type JobsIteration struct {
|
||||
Jobs []*job
|
||||
Queue int
|
||||
c chan struct{}
|
||||
}
|
||||
|
||||
func (j *JobsIteration) Done() {
|
||||
if j.c != nil {
|
||||
close(j.c)
|
||||
}
|
||||
}
|
||||
|
||||
type BatchUuidsProviderStrategy interface {
|
||||
Queue(job *job)
|
||||
GetJobs(abort context.Context) <-chan *JobsIteration
|
||||
}
|
||||
|
||||
type PeriodicStrategy struct {
|
||||
Delay time.Duration
|
||||
Batch int
|
||||
queue *jobsQueue
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewPeriodicStrategy(delay time.Duration, batch int) *PeriodicStrategy {
|
||||
return &PeriodicStrategy{
|
||||
Delay: delay,
|
||||
Batch: batch,
|
||||
queue: newJobsQueue(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *PeriodicStrategy) Queue(job *job) {
|
||||
ctx.queue.Enqueue(job)
|
||||
}
|
||||
|
||||
func (ctx *PeriodicStrategy) GetJobs(abort context.Context) <-chan *JobsIteration {
|
||||
ch := make(chan *JobsIteration)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-abort.Done():
|
||||
close(ch)
|
||||
return
|
||||
case <-time.After(ctx.Delay):
|
||||
jobs, queueLen := ctx.queue.Dequeue(ctx.Batch)
|
||||
jobDoneChan := make(chan struct{})
|
||||
ch <- &JobsIteration{jobs, queueLen, jobDoneChan}
|
||||
<-jobDoneChan
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
type FullBusStrategy struct {
|
||||
Delay time.Duration
|
||||
Batch int
|
||||
queue *jobsQueue
|
||||
busIsFull chan bool
|
||||
}
|
||||
|
||||
func NewFullBusStrategy(delay time.Duration, batch int) *FullBusStrategy {
|
||||
return &FullBusStrategy{
|
||||
Delay: delay,
|
||||
Batch: batch,
|
||||
queue: newJobsQueue(),
|
||||
busIsFull: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *FullBusStrategy) Queue(job *job) {
|
||||
n := ctx.queue.Enqueue(job)
|
||||
if n%ctx.Batch == 0 {
|
||||
ctx.busIsFull <- true
|
||||
}
|
||||
}
|
||||
|
||||
// Формально, это описание логики водителя маршрутки xD
|
||||
func (ctx *FullBusStrategy) GetJobs(abort context.Context) <-chan *JobsIteration {
|
||||
ch := make(chan *JobsIteration)
|
||||
go func() {
|
||||
for {
|
||||
t := time.NewTimer(ctx.Delay)
|
||||
select {
|
||||
case <-abort.Done():
|
||||
close(ch)
|
||||
return
|
||||
case <-t.C:
|
||||
ctx.sendJobs(ch)
|
||||
case <-ctx.busIsFull:
|
||||
t.Stop()
|
||||
ctx.sendJobs(ch)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (ctx *FullBusStrategy) sendJobs(ch chan *JobsIteration) {
|
||||
jobs, queueLen := ctx.queue.Dequeue(ctx.Batch)
|
||||
ch <- &JobsIteration{jobs, queueLen, nil}
|
||||
}
|
||||
|
||||
type BatchUuidsProvider struct {
|
||||
context context.Context
|
||||
emitter Emitter
|
||||
strategy BatchUuidsProviderStrategy
|
||||
onFirstCall sync.Once
|
||||
}
|
||||
|
||||
func NewBatchUuidsProvider(
|
||||
context context.Context,
|
||||
strategy BatchUuidsProviderStrategy,
|
||||
emitter Emitter,
|
||||
) *BatchUuidsProvider {
|
||||
return &BatchUuidsProvider{
|
||||
context: context,
|
||||
emitter: emitter,
|
||||
strategy: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
|
||||
ctx.onFirstCall.Do(ctx.startQueue)
|
||||
|
||||
resultChan := make(chan *jobResult)
|
||||
ctx.strategy.Queue(&job{username, resultChan})
|
||||
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:queued", username)
|
||||
|
||||
result := <-resultChan
|
||||
|
||||
return result.Profile, result.Error
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) startQueue() {
|
||||
// This synchronization chan is used to ensure that strategy's jobs provider
|
||||
// will be initialized before any job will be scheduled
|
||||
d := make(chan struct{})
|
||||
go func() {
|
||||
jobsChan := ctx.strategy.GetJobs(ctx.context)
|
||||
close(d)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.context.Done():
|
||||
return
|
||||
case iteration := <-jobsChan:
|
||||
go func() {
|
||||
ctx.performRequest(iteration)
|
||||
iteration.Done()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-d
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) performRequest(iteration *JobsIteration) {
|
||||
usernames := make([]string, len(iteration.Jobs))
|
||||
for i, job := range iteration.Jobs {
|
||||
usernames[i] = job.Username
|
||||
}
|
||||
|
||||
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:round", usernames, iteration.Queue)
|
||||
if len(usernames) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
profiles, err := usernamesToUuids(usernames)
|
||||
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err)
|
||||
for _, job := range iteration.Jobs {
|
||||
response := &jobResult{}
|
||||
if err == nil {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.Error = err
|
||||
}
|
||||
|
||||
job.RespondChan <- response
|
||||
close(job.RespondChan)
|
||||
}
|
||||
}
|
@ -1,441 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
func TestJobsQueue(t *testing.T) {
|
||||
t.Run("Enqueue", func(t *testing.T) {
|
||||
s := newJobsQueue()
|
||||
require.Equal(t, 1, s.Enqueue(&job{Username: "username1"}))
|
||||
require.Equal(t, 2, s.Enqueue(&job{Username: "username2"}))
|
||||
require.Equal(t, 3, s.Enqueue(&job{Username: "username3"}))
|
||||
})
|
||||
|
||||
t.Run("Dequeue", func(t *testing.T) {
|
||||
s := newJobsQueue()
|
||||
s.Enqueue(&job{Username: "username1"})
|
||||
s.Enqueue(&job{Username: "username2"})
|
||||
s.Enqueue(&job{Username: "username3"})
|
||||
s.Enqueue(&job{Username: "username4"})
|
||||
s.Enqueue(&job{Username: "username5"})
|
||||
|
||||
items, queueLen := s.Dequeue(2)
|
||||
require.Len(t, items, 2)
|
||||
require.Equal(t, 3, queueLen)
|
||||
require.Equal(t, "username1", items[0].Username)
|
||||
require.Equal(t, "username2", items[1].Username)
|
||||
|
||||
items, queueLen = s.Dequeue(40)
|
||||
require.Len(t, items, 3)
|
||||
require.Equal(t, 0, queueLen)
|
||||
require.Equal(t, "username3", items[0].Username)
|
||||
require.Equal(t, "username4", items[1].Username)
|
||||
require.Equal(t, "username5", items[2].Username)
|
||||
})
|
||||
}
|
||||
|
||||
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 manualStrategy struct {
|
||||
ch chan *JobsIteration
|
||||
once sync.Once
|
||||
lock sync.Mutex
|
||||
jobs []*job
|
||||
}
|
||||
|
||||
func (m *manualStrategy) Queue(job *job) {
|
||||
m.lock.Lock()
|
||||
m.jobs = append(m.jobs, job)
|
||||
m.lock.Unlock()
|
||||
}
|
||||
|
||||
func (m *manualStrategy) GetJobs(_ context.Context) <-chan *JobsIteration {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
m.ch = make(chan *JobsIteration)
|
||||
|
||||
return m.ch
|
||||
}
|
||||
|
||||
func (m *manualStrategy) Iterate(countJobsToReturn int, countLeftJobsInQueue int) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
m.ch <- &JobsIteration{
|
||||
Jobs: m.jobs[0:countJobsToReturn],
|
||||
Queue: countLeftJobsInQueue,
|
||||
}
|
||||
}
|
||||
|
||||
type batchUuidsProviderGetUuidResult struct {
|
||||
Result *mojang.ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
type batchUuidsProviderTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *BatchUuidsProvider
|
||||
|
||||
Emitter *mockEmitter
|
||||
Strategy *manualStrategy
|
||||
MojangApi *mojangUsernamesToUuidsRequestMock
|
||||
|
||||
stop context.CancelFunc
|
||||
}
|
||||
|
||||
func (suite *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
|
||||
s := make(chan struct{})
|
||||
// This dirty hack ensures, that the username will be queued before we return control to the caller.
|
||||
// It's needed to keep expected calls order and prevent cases when iteration happens before
|
||||
// all usernames will be queued.
|
||||
suite.Emitter.On("Emit",
|
||||
"mojang_textures:batch_uuids_provider:queued",
|
||||
username,
|
||||
).Once().Run(func(args mock.Arguments) {
|
||||
close(s)
|
||||
})
|
||||
|
||||
c := make(chan *batchUuidsProviderGetUuidResult)
|
||||
go func() {
|
||||
profile, err := suite.Provider.GetUuid(username)
|
||||
c <- &batchUuidsProviderGetUuidResult{
|
||||
Result: profile,
|
||||
Error: err,
|
||||
}
|
||||
}()
|
||||
|
||||
<-s
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (suite *batchUuidsProviderTestSuite) SetupTest() {
|
||||
suite.Emitter = &mockEmitter{}
|
||||
suite.Strategy = &manualStrategy{}
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
suite.stop = stop
|
||||
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
|
||||
usernamesToUuids = suite.MojangApi.UsernamesToUuids
|
||||
|
||||
suite.Provider = NewBatchUuidsProvider(ctx, suite.Strategy, suite.Emitter)
|
||||
}
|
||||
|
||||
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
|
||||
suite.stop()
|
||||
suite.Emitter.AssertExpectations(suite.T())
|
||||
suite.MojangApi.AssertExpectations(suite.T())
|
||||
}
|
||||
|
||||
func TestBatchUuidsProvider(t *testing.T) {
|
||||
suite.Run(t, new(batchUuidsProviderTestSuite))
|
||||
}
|
||||
|
||||
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernames() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
|
||||
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
|
||||
expectedResponse := []*mojang.ProfileInfo{expectedResult1, expectedResult2}
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{
|
||||
expectedResult1,
|
||||
expectedResult2,
|
||||
}, nil)
|
||||
|
||||
resultChan1 := suite.GetUuidAsync("username1")
|
||||
resultChan2 := suite.GetUuidAsync("username2")
|
||||
|
||||
suite.Strategy.Iterate(2, 0)
|
||||
|
||||
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) TestShouldNotSendRequestWhenNoJobsAreReturned() {
|
||||
//noinspection GoPreferNilSlice
|
||||
emptyUsernames := []string{}
|
||||
done := make(chan struct{})
|
||||
suite.Emitter.On("Emit",
|
||||
"mojang_textures:batch_uuids_provider:round",
|
||||
emptyUsernames,
|
||||
1,
|
||||
).Once().Run(func(args mock.Arguments) {
|
||||
close(done)
|
||||
})
|
||||
|
||||
suite.GetUuidAsync("username") // Schedule one username to run the queue
|
||||
|
||||
suite.Strategy.Iterate(0, 1) // Return no jobs and indicate that there is one job in queue
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
// Test written for multiple usernames to ensure that the error
|
||||
// will be returned for each iteration group
|
||||
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedError := &mojang.TooManyRequestsError{}
|
||||
var nilProfilesResponse []*mojang.ProfileInfo
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, nilProfilesResponse, expectedError).Once()
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
|
||||
|
||||
resultChan1 := suite.GetUuidAsync("username1")
|
||||
resultChan2 := suite.GetUuidAsync("username2")
|
||||
|
||||
suite.Strategy.Iterate(2, 0)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestPeriodicStrategy(t *testing.T) {
|
||||
t.Run("should return first job only after duration", func(t *testing.T) {
|
||||
d := 20 * time.Millisecond
|
||||
strategy := NewPeriodicStrategy(d, 10)
|
||||
j := &job{}
|
||||
strategy.Queue(j)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
startedAt := time.Now()
|
||||
ch := strategy.GetJobs(ctx)
|
||||
iteration := <-ch
|
||||
durationBeforeResult := time.Now().Sub(startedAt)
|
||||
require.True(t, durationBeforeResult >= d)
|
||||
require.True(t, durationBeforeResult < d*2)
|
||||
|
||||
require.Equal(t, []*job{j}, iteration.Jobs)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
|
||||
cancel()
|
||||
})
|
||||
|
||||
t.Run("should return the configured batch size", func(t *testing.T) {
|
||||
strategy := NewPeriodicStrategy(0, 10)
|
||||
jobs := make([]*job, 15)
|
||||
for i := 0; i < 15; i++ {
|
||||
jobs[i] = &job{Username: strconv.Itoa(i)}
|
||||
strategy.Queue(jobs[i])
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := strategy.GetJobs(ctx)
|
||||
iteration := <-ch
|
||||
require.Len(t, iteration.Jobs, 10)
|
||||
require.Equal(t, jobs[0:10], iteration.Jobs)
|
||||
require.Equal(t, 5, iteration.Queue)
|
||||
|
||||
cancel()
|
||||
})
|
||||
|
||||
t.Run("should not return the next iteration until the previous one is finished", func(t *testing.T) {
|
||||
strategy := NewPeriodicStrategy(0, 10)
|
||||
strategy.Queue(&job{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := strategy.GetJobs(ctx)
|
||||
iteration := <-ch
|
||||
require.Len(t, iteration.Jobs, 1)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
|
||||
time.Sleep(time.Millisecond) // Let strategy's internal loop to work (if the implementation is broken)
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
require.Fail(t, "the previous iteration isn't marked as done")
|
||||
default:
|
||||
// ok
|
||||
}
|
||||
|
||||
iteration.Done()
|
||||
|
||||
time.Sleep(time.Millisecond) // Let strategy's internal loop to work
|
||||
|
||||
select {
|
||||
case iteration = <-ch:
|
||||
// ok
|
||||
default:
|
||||
require.Fail(t, "iteration should be provided")
|
||||
}
|
||||
|
||||
require.Empty(t, iteration.Jobs)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
iteration.Done()
|
||||
|
||||
cancel()
|
||||
})
|
||||
|
||||
t.Run("each iteration should be returned only after the configured duration", func(t *testing.T) {
|
||||
d := 5 * time.Millisecond
|
||||
strategy := NewPeriodicStrategy(d, 10)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := strategy.GetJobs(ctx)
|
||||
for i := 0; i < 3; i++ {
|
||||
startedAt := time.Now()
|
||||
iteration := <-ch
|
||||
durationBeforeResult := time.Now().Sub(startedAt)
|
||||
require.True(t, durationBeforeResult >= d)
|
||||
require.True(t, durationBeforeResult < d*2)
|
||||
|
||||
require.Empty(t, iteration.Jobs)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
|
||||
// Sleep for at least doubled duration before calling Done() to check,
|
||||
// that this duration isn't included into the next iteration time
|
||||
time.Sleep(d * 2)
|
||||
iteration.Done()
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
func TestFullBusStrategy(t *testing.T) {
|
||||
t.Run("should provide iteration immediately when the batch size exceeded", func(t *testing.T) {
|
||||
jobs := make([]*job, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
jobs[i] = &job{}
|
||||
}
|
||||
|
||||
d := 20 * time.Millisecond
|
||||
strategy := NewFullBusStrategy(d, 10)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := strategy.GetJobs(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
select {
|
||||
case iteration := <-ch:
|
||||
require.Len(t, iteration.Jobs, 10)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
case <-time.After(d):
|
||||
require.Fail(t, "iteration should be provided immediately")
|
||||
}
|
||||
}()
|
||||
|
||||
for _, j := range jobs {
|
||||
strategy.Queue(j)
|
||||
}
|
||||
|
||||
<-done
|
||||
|
||||
cancel()
|
||||
})
|
||||
|
||||
t.Run("should provide iteration after duration if batch size isn't exceeded", func(t *testing.T) {
|
||||
jobs := make([]*job, 9)
|
||||
for i := 0; i < 9; i++ {
|
||||
jobs[i] = &job{}
|
||||
}
|
||||
|
||||
d := 20 * time.Millisecond
|
||||
strategy := NewFullBusStrategy(d, 10)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
startedAt := time.Now()
|
||||
ch := strategy.GetJobs(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
iteration := <-ch
|
||||
duration := time.Now().Sub(startedAt)
|
||||
require.True(t, duration >= d, fmt.Sprintf("has %d, expected %d", duration, d))
|
||||
require.True(t, duration < d*2)
|
||||
require.Equal(t, jobs, iteration.Jobs)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
}()
|
||||
|
||||
for _, j := range jobs {
|
||||
strategy.Queue(j)
|
||||
}
|
||||
|
||||
<-done
|
||||
|
||||
cancel()
|
||||
})
|
||||
|
||||
t.Run("should provide iteration as soon as the bus is full, without waiting for the previous iteration to finish", func(t *testing.T) {
|
||||
d := 20 * time.Millisecond
|
||||
strategy := NewFullBusStrategy(d, 10)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := strategy.GetJobs(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(5 * time.Millisecond) // See comment below
|
||||
select {
|
||||
case iteration := <-ch:
|
||||
require.Len(t, iteration.Jobs, 10)
|
||||
// Don't assert iteration.Queue length since it might be unstable
|
||||
// Don't call iteration.Done()
|
||||
case <-time.After(d):
|
||||
t.Errorf("iteration should be provided as soon as the bus is full")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled 31 tasks. 3 iterations should be performed immediately
|
||||
// and should be executed only after timeout. The timeout above is used
|
||||
// to increase overall time to ensure, that timer resets on every iteration
|
||||
|
||||
startedAt := time.Now()
|
||||
iteration := <-ch
|
||||
duration := time.Now().Sub(startedAt)
|
||||
require.True(t, duration >= d)
|
||||
require.True(t, duration < d*2)
|
||||
require.Len(t, iteration.Jobs, 1)
|
||||
require.Equal(t, 0, iteration.Queue)
|
||||
}()
|
||||
|
||||
for i := 0; i < 31; i++ {
|
||||
strategy.Queue(&job{})
|
||||
}
|
||||
|
||||
<-done
|
||||
|
||||
cancel()
|
||||
})
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/utils"
|
||||
)
|
||||
|
||||
type inMemoryItem struct {
|
||||
textures *mojang.SignedTexturesResponse
|
||||
timestamp int64
|
||||
}
|
||||
|
||||
type InMemoryTexturesStorage struct {
|
||||
GCPeriod time.Duration
|
||||
Duration time.Duration
|
||||
|
||||
once sync.Once
|
||||
lock sync.RWMutex
|
||||
data map[string]*inMemoryItem
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
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) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
item, exists := s.data[uuid]
|
||||
validRange := s.getMinimalNotExpiredTimestamp()
|
||||
if !exists || validRange > item.timestamp {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return item.textures, nil
|
||||
}
|
||||
|
||||
func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
|
||||
s.once.Do(s.start)
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.data[uuid] = &inMemoryItem{
|
||||
textures: textures,
|
||||
timestamp: utils.UnixMillisecond(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InMemoryTexturesStorage) start() {
|
||||
s.done = make(chan struct{})
|
||||
ticker := time.NewTicker(s.GCPeriod)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.gc()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *InMemoryTexturesStorage) Stop() {
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
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 utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1)))
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
assert "github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
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("should return nil, nil when textures are unavailable", func(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
|
||||
|
||||
assert.Nil(t, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
|
||||
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
assert.Equal(t, texturesWithSkin, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should return nil, nil when textures are exists, but cache duration is expired", func(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
storage.Duration = 10 * time.Millisecond
|
||||
storage.GCPeriod = time.Minute
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
|
||||
|
||||
time.Sleep(storage.Duration * 2)
|
||||
|
||||
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
assert.Nil(t, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
|
||||
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
|
||||
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
assert.Equal(t, texturesWithSkin, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("override already existed textures for uuid", func(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
|
||||
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
assert.NotEqual(t, texturesWithoutSkin, result)
|
||||
assert.Equal(t, texturesWithSkin, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("store textures with empty properties", func(t *testing.T) {
|
||||
texturesWithEmptyProps := &mojang.SignedTexturesResponse{
|
||||
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
Name: "mock",
|
||||
Props: []*mojang.Property{},
|
||||
}
|
||||
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithEmptyProps)
|
||||
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
assert.Exactly(t, texturesWithEmptyProps, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("store nil textures", func(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
|
||||
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
assert.Nil(t, result)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
|
||||
storage := NewInMemoryTexturesStorage()
|
||||
defer storage.Stop()
|
||||
storage.GCPeriod = 10 * time.Millisecond
|
||||
storage.Duration = 9 * time.Millisecond
|
||||
|
||||
textures1 := &mojang.SignedTexturesResponse{
|
||||
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
Name: "mock1",
|
||||
Props: []*mojang.Property{},
|
||||
}
|
||||
textures2 := &mojang.SignedTexturesResponse{
|
||||
Id: "b5d58475007d4f9e9ddd1403e2497579",
|
||||
Name: "mock2",
|
||||
Props: []*mojang.Property{},
|
||||
}
|
||||
|
||||
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
|
||||
// Store another texture a bit later to avoid it removing by GC after the first iteration
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
|
||||
|
||||
storage.lock.RLock()
|
||||
assert.Len(t, storage.data, 2, "the GC period has not yet reached")
|
||||
storage.lock.RUnlock()
|
||||
|
||||
time.Sleep(storage.GCPeriod) // Let it perform the first GC iteration
|
||||
|
||||
storage.lock.RLock()
|
||||
assert.Len(t, storage.data, 1, "the first texture should be cleaned by GC")
|
||||
assert.Contains(t, storage.data, "b5d58475007d4f9e9ddd1403e2497579")
|
||||
storage.lock.RUnlock()
|
||||
|
||||
time.Sleep(storage.GCPeriod) // Let another iteration happen
|
||||
|
||||
storage.lock.RLock()
|
||||
assert.Len(t, storage.data, 0)
|
||||
storage.lock.RUnlock()
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
var uuidToTextures = mojang.UuidToTextures
|
||||
|
||||
type MojangApiTexturesProvider struct {
|
||||
Emitter
|
||||
}
|
||||
|
||||
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid)
|
||||
result, err := uuidToTextures(uuid, true)
|
||||
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", uuid, result, err)
|
||||
|
||||
return result, err
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
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
|
||||
Emitter *mockEmitter
|
||||
MojangApi *mojangUuidToTexturesRequestMock
|
||||
}
|
||||
|
||||
func (suite *mojangApiTexturesProviderTestSuite) SetupTest() {
|
||||
suite.Emitter = &mockEmitter{}
|
||||
suite.MojangApi = &mojangUuidToTexturesRequestMock{}
|
||||
|
||||
suite.Provider = &MojangApiTexturesProvider{
|
||||
Emitter: suite.Emitter,
|
||||
}
|
||||
|
||||
uuidToTextures = suite.MojangApi.UuidToTextures
|
||||
}
|
||||
|
||||
func (suite *mojangApiTexturesProviderTestSuite) TearDownTest() {
|
||||
suite.MojangApi.AssertExpectations(suite.T())
|
||||
suite.Emitter.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.Emitter.On("Emit",
|
||||
"mojang_textures:mojang_api_textures_provider:before_request",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
).Once()
|
||||
suite.Emitter.On("Emit",
|
||||
"mojang_textures:mojang_api_textures_provider:after_request",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
expectedResult,
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
suite.Assert().Nil(err)
|
||||
}
|
||||
|
||||
func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
|
||||
var expectedResponse *mojang.SignedTexturesResponse
|
||||
expectedError := &mojang.TooManyRequestsError{}
|
||||
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
|
||||
|
||||
suite.Emitter.On("Emit",
|
||||
"mojang_textures:mojang_api_textures_provider:before_request",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
).Once()
|
||||
suite.Emitter.On("Emit",
|
||||
"mojang_textures:mojang_api_textures_provider:after_request",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
expectedResponse,
|
||||
expectedError,
|
||||
).Once()
|
||||
|
||||
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(expectedError, err)
|
||||
}
|
@ -1,205 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/dispatcher"
|
||||
)
|
||||
|
||||
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.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV
|
||||
var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{3,16}$`)
|
||||
|
||||
type UUIDsProvider interface {
|
||||
GetUuid(username string) (*mojang.ProfileInfo, error)
|
||||
}
|
||||
|
||||
type TexturesProvider interface {
|
||||
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
dispatcher.Emitter
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Emitter
|
||||
UUIDsProvider
|
||||
TexturesProvider
|
||||
Storage
|
||||
|
||||
onFirstCall sync.Once
|
||||
*broadcaster
|
||||
}
|
||||
|
||||
func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
|
||||
ctx.onFirstCall.Do(func() {
|
||||
ctx.broadcaster = createBroadcaster()
|
||||
})
|
||||
|
||||
if !allowedUsernamesRegex.MatchString(username) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
username = strings.ToLower(username)
|
||||
ctx.Emit("mojang_textures:call", username)
|
||||
|
||||
uuid, found, err := ctx.getUuidFromCache(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if found && uuid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if uuid != "" {
|
||||
textures, err := ctx.getTexturesFromCache(uuid)
|
||||
if err == nil && textures != nil {
|
||||
return textures, nil
|
||||
}
|
||||
}
|
||||
|
||||
resultChan := make(chan *broadcastResult)
|
||||
isFirstListener := ctx.broadcaster.AddListener(username, resultChan)
|
||||
if isFirstListener {
|
||||
go ctx.getResultAndBroadcast(username, uuid)
|
||||
} else {
|
||||
ctx.Emit("mojang_textures:already_processing", username)
|
||||
}
|
||||
|
||||
result := <-resultChan
|
||||
|
||||
return result.textures, result.error
|
||||
}
|
||||
|
||||
func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
|
||||
ctx.Emit("mojang_textures:before_result", username, uuid)
|
||||
result := ctx.getResult(username, uuid)
|
||||
ctx.Emit("mojang_textures:after_result", username, result.textures, result.error)
|
||||
|
||||
ctx.broadcaster.BroadcastAndRemove(username, result)
|
||||
}
|
||||
|
||||
func (ctx *Provider) getResult(username string, cachedUuid string) *broadcastResult {
|
||||
uuid := cachedUuid
|
||||
if uuid == "" {
|
||||
profile, err := ctx.getUuid(username)
|
||||
if err != nil {
|
||||
return &broadcastResult{nil, err}
|
||||
}
|
||||
|
||||
uuid = ""
|
||||
if profile != nil {
|
||||
uuid = profile.Id
|
||||
}
|
||||
|
||||
_ = ctx.Storage.StoreUuid(username, uuid)
|
||||
|
||||
if uuid == "" {
|
||||
return &broadcastResult{nil, nil}
|
||||
}
|
||||
}
|
||||
|
||||
textures, err := ctx.getTextures(uuid)
|
||||
if err != nil {
|
||||
// Previously cached UUIDs may disappear
|
||||
// In this case we must invalidate UUID cache for given username
|
||||
if _, ok := err.(*mojang.EmptyResponse); ok && cachedUuid != "" {
|
||||
return ctx.getResult(username, "")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return &broadcastResult{textures, nil}
|
||||
}
|
||||
|
||||
func (ctx *Provider) getUuidFromCache(username string) (string, bool, error) {
|
||||
ctx.Emit("mojang_textures:usernames:before_cache", username)
|
||||
uuid, found, err := ctx.Storage.GetUuid(username)
|
||||
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, found, err)
|
||||
|
||||
return uuid, found, err
|
||||
}
|
||||
|
||||
func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
ctx.Emit("mojang_textures:textures:before_cache", uuid)
|
||||
textures, err := ctx.Storage.GetTextures(uuid)
|
||||
ctx.Emit("mojang_textures:textures:after_cache", uuid, textures, err)
|
||||
|
||||
return textures, err
|
||||
}
|
||||
|
||||
func (ctx *Provider) getUuid(username string) (*mojang.ProfileInfo, error) {
|
||||
ctx.Emit("mojang_textures:usernames:before_call", username)
|
||||
profile, err := ctx.UUIDsProvider.GetUuid(username)
|
||||
ctx.Emit("mojang_textures:usernames:after_call", username, profile, err)
|
||||
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func (ctx *Provider) getTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
ctx.Emit("mojang_textures:textures:before_call", uuid)
|
||||
textures, err := ctx.TexturesProvider.GetTextures(uuid)
|
||||
ctx.Emit("mojang_textures:textures:after_call", uuid, textures, err)
|
||||
|
||||
return textures, err
|
||||
}
|
@ -1,457 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
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 mockEmitter struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (e *mockEmitter) Emit(name string, args ...interface{}) {
|
||||
e.Called(append([]interface{}{name}, args...)...)
|
||||
}
|
||||
|
||||
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, bool, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.Bool(1), args.Error(2)
|
||||
}
|
||||
|
||||
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
|
||||
Emitter *mockEmitter
|
||||
UuidsProvider *mockUuidsProvider
|
||||
TexturesProvider *mockTexturesProvider
|
||||
Storage *mockStorage
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) SetupTest() {
|
||||
suite.Emitter = &mockEmitter{}
|
||||
suite.UuidsProvider = &mockUuidsProvider{}
|
||||
suite.TexturesProvider = &mockTexturesProvider{}
|
||||
suite.Storage = &mockStorage{}
|
||||
|
||||
suite.Provider = &Provider{
|
||||
Emitter: suite.Emitter,
|
||||
UUIDsProvider: suite.UuidsProvider,
|
||||
TexturesProvider: suite.TexturesProvider,
|
||||
Storage: suite.Storage,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TearDownTest() {
|
||||
suite.Emitter.AssertExpectations(suite.T())
|
||||
suite.UuidsProvider.AssertExpectations(suite.T())
|
||||
suite.TexturesProvider.AssertExpectations(suite.T())
|
||||
suite.Storage.AssertExpectations(suite.T())
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
suite.Run(t, new(providerTestSuite))
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
|
||||
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
|
||||
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
|
||||
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, 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() {
|
||||
var expectedCachedTextures *mojang.SignedTexturesResponse
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
|
||||
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
|
||||
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.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, 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.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", true, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("", true, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Nil(err)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
|
||||
var expectedProfile *mojang.ProfileInfo
|
||||
var expectedResult *mojang.SignedTexturesResponse
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
|
||||
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() {
|
||||
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
var expectedResult *mojang.SignedTexturesResponse
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
|
||||
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
|
||||
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, 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)
|
||||
}
|
||||
|
||||
// https://github.com/elyby/chrly/issues/29
|
||||
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuidThatHasBeenDisappeared() {
|
||||
expectedErr := &mojang.EmptyResponse{}
|
||||
expectedProfile := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
|
||||
var nilTexturesResponse *mojang.SignedTexturesResponse
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, expectedErr).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
|
||||
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
|
||||
suite.Storage.On("StoreUuid", "username", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once().Return(nil)
|
||||
suite.Storage.On("StoreTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult).Once()
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, expectedErr)
|
||||
suite.TexturesProvider.On("GetTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Return(expectedResult, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().Nil(err)
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
|
||||
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Twice()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Twice()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Twice()
|
||||
suite.Emitter.On("Emit", "mojang_textures:already_processing", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Twice().Return("", false, nil)
|
||||
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(expectedProfile, 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() {
|
||||
result, err := suite.Provider.GetForUsername("Not allowed")
|
||||
suite.Assert().Nil(err)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromUUIDsStorage() {
|
||||
expectedErr := errors.New("mock error")
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, expectedErr).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("", false, expectedErr)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(expectedErr, err)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
|
||||
var expectedProfile *mojang.ProfileInfo
|
||||
var expectedResult *mojang.SignedTexturesResponse
|
||||
err := errors.New("mock error")
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, err).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
|
||||
|
||||
result, resErr := suite.Provider.GetForUsername("username")
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(err, resErr)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
|
||||
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
var expectedResult *mojang.SignedTexturesResponse
|
||||
err := errors.New("mock error")
|
||||
|
||||
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, err).Once()
|
||||
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "username").Return("", false, nil)
|
||||
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
|
||||
|
||||
result, resErr := suite.Provider.GetForUsername("username")
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(err, resErr)
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
type NilProvider struct {
|
||||
}
|
||||
|
||||
func (p *NilProvider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
|
||||
return nil, nil
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
package mojangtextures
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNilProvider_GetForUsername(t *testing.T) {
|
||||
provider := &NilProvider{}
|
||||
result, err := provider.GetForUsername("username")
|
||||
assert.Nil(t, result)
|
||||
assert.Nil(t, err)
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
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 {
|
||||
// The second argument indicates whether a record was found in the storage,
|
||||
// since depending on it, the empty value must be interpreted as "no cached record"
|
||||
// or "value cached and has an empty value"
|
||||
GetUuid(username string) (uuid string, found bool, err 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, bool, 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)
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
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, bool, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.Bool(1), args.Error(2)
|
||||
}
|
||||
|
||||
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", true, nil)
|
||||
result, found, err := storage.GetUuid("username")
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, found)
|
||||
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)
|
||||
})
|
||||
}
|
40
utils/queue.go
Normal file
40
utils/queue.go
Normal file
@ -0,0 +1,40 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Queue[T any] struct {
|
||||
lock sync.Mutex
|
||||
items []T
|
||||
}
|
||||
|
||||
func NewQueue[T any]() *Queue[T] {
|
||||
return &Queue[T]{
|
||||
items: []T{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Queue[T]) Enqueue(item T) int {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.items = append(s.items, item)
|
||||
|
||||
return len(s.items)
|
||||
}
|
||||
|
||||
func (s *Queue[T]) Dequeue(n int) ([]T, int) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
l := len(s.items)
|
||||
if n > l {
|
||||
n = l
|
||||
}
|
||||
|
||||
items := s.items[0:n]
|
||||
s.items = s.items[n:l]
|
||||
|
||||
return items, l - n
|
||||
}
|
38
utils/queue_test.go
Normal file
38
utils/queue_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQueue(t *testing.T) {
|
||||
t.Run("Enqueue", func(t *testing.T) {
|
||||
s := NewQueue[string]()
|
||||
require.Equal(t, 1, s.Enqueue("username1"))
|
||||
require.Equal(t, 2, s.Enqueue("username2"))
|
||||
require.Equal(t, 3, s.Enqueue("username3"))
|
||||
})
|
||||
|
||||
t.Run("Dequeue", func(t *testing.T) {
|
||||
s := NewQueue[string]()
|
||||
s.Enqueue("username1")
|
||||
s.Enqueue("username2")
|
||||
s.Enqueue("username3")
|
||||
s.Enqueue("username4")
|
||||
s.Enqueue("username5")
|
||||
|
||||
items, queueLen := s.Dequeue(2)
|
||||
require.Len(t, items, 2)
|
||||
require.Equal(t, 3, queueLen)
|
||||
require.Equal(t, "username1", items[0])
|
||||
require.Equal(t, "username2", items[1])
|
||||
|
||||
items, queueLen = s.Dequeue(40)
|
||||
require.Len(t, items, 3)
|
||||
require.Equal(t, 0, queueLen)
|
||||
require.Equal(t, "username3", items[0])
|
||||
require.Equal(t, "username4", items[1])
|
||||
require.Equal(t, "username5", items[2])
|
||||
})
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user