mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	Rewrite mojang textures provider module, cleanup its implementation of events emitter, statsd and etc.
This commit is contained in:
		| @@ -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]) | ||||
| 	}) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user