mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	Rework project's structure
This commit is contained in:
		
							
								
								
									
										114
									
								
								internal/mojang/batch_uuids_provider.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								internal/mojang/batch_uuids_provider.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| package mojang | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/elyby/chrly/internal/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
									
								
								internal/mojang/batch_uuids_provider_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								internal/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)) | ||||
| } | ||||
							
								
								
									
										265
									
								
								internal/mojang/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								internal/mojang/client.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,265 @@ | ||||
| package mojang | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| ) | ||||
|  | ||||
| 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) (*ProfileResponse, 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 *ProfileResponse | ||||
|  | ||||
| 	body, _ := io.ReadAll(response.Body) | ||||
| 	err = json.Unmarshal(body, &result) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return result, nil | ||||
| } | ||||
|  | ||||
| type ProfileResponse struct { | ||||
| 	Id    string      `json:"id"` | ||||
| 	Name  string      `json:"name"` | ||||
| 	Props []*Property `json:"properties"` | ||||
|  | ||||
| 	once            sync.Once | ||||
| 	decodedTextures *TexturesProp | ||||
| 	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 *ProfileResponse) DecodeTextures() (*TexturesProp, error) { | ||||
| 	t.once.Do(func() { | ||||
| 		var texturesProp string | ||||
| 		for _, prop := range t.Props { | ||||
| 			if prop.Name == "textures" { | ||||
| 				texturesProp = prop.Value | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if texturesProp == "" { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		decodedTextures, err := DecodeTextures(texturesProp) | ||||
| 		if err != nil { | ||||
| 			t.decodedErr = err | ||||
| 		} else { | ||||
| 			t.decodedTextures = decodedTextures | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	return t.decodedTextures, t.decodedErr | ||||
| } | ||||
|  | ||||
| type Property struct { | ||||
| 	Name      string `json:"name"` | ||||
| 	Signature string `json:"signature,omitempty"` | ||||
| 	Value     string `json:"value"` | ||||
| } | ||||
|  | ||||
| type ProfileInfo struct { | ||||
| 	Id       string `json:"id"` | ||||
| 	Name     string `json:"name"` | ||||
| 	IsLegacy bool   `json:"legacy,omitempty"` | ||||
| 	IsDemo   bool   `json:"demo,omitempty"` | ||||
| } | ||||
|  | ||||
| func errorFromResponse(response *http.Response) error { | ||||
| 	switch { | ||||
| 	case response.StatusCode == 400: | ||||
| 		type errorResponse struct { | ||||
| 			Error   string `json:"error"` | ||||
| 			Message string `json:"errorMessage"` | ||||
| 		} | ||||
|  | ||||
| 		var decodedError *errorResponse | ||||
| 		body, _ := io.ReadAll(response.Body) | ||||
| 		_ = json.Unmarshal(body, &decodedError) | ||||
|  | ||||
| 		return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message} | ||||
| 	case response.StatusCode == 403: | ||||
| 		return &ForbiddenError{} | ||||
| 	case response.StatusCode == 429: | ||||
| 		return &TooManyRequestsError{} | ||||
| 	case response.StatusCode >= 500: | ||||
| 		return &ServerError{Status: response.StatusCode} | ||||
| 	} | ||||
|  | ||||
| 	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 { | ||||
| 	ErrorType string | ||||
| 	Message   string | ||||
| } | ||||
|  | ||||
| func (e *BadRequestError) Error() string { | ||||
| 	return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message) | ||||
| } | ||||
|  | ||||
| // When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization) | ||||
| type ForbiddenError struct { | ||||
| } | ||||
|  | ||||
| func (*ForbiddenError) Error() string { | ||||
| 	return "403: Forbidden" | ||||
| } | ||||
|  | ||||
| // When you exceed the set limit of requests, this error will be returned | ||||
| type TooManyRequestsError struct { | ||||
| } | ||||
|  | ||||
| func (*TooManyRequestsError) Error() string { | ||||
| 	return "429: Too Many Requests" | ||||
| } | ||||
|  | ||||
| // ServerError happens when Mojang's API returns any response with 50* status | ||||
| type ServerError struct { | ||||
| 	Status int | ||||
| } | ||||
|  | ||||
| func (e *ServerError) Error() string { | ||||
| 	return fmt.Sprintf("%d: %s", e.Status, "Server error") | ||||
| } | ||||
|  | ||||
| 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
									
								
								internal/mojang/client_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										318
									
								
								internal/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 := &ProfileResponse{ | ||||
| 			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 := &ProfileResponse{ | ||||
| 			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
									
								
								internal/mojang/provider.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								internal/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) (*ProfileResponse, error) | ||||
| } | ||||
|  | ||||
| type MojangTexturesProvider struct { | ||||
| 	UuidsProvider | ||||
| 	TexturesProvider | ||||
|  | ||||
| 	group singleflight.Group[string, *ProfileResponse] | ||||
| } | ||||
|  | ||||
| func (p *MojangTexturesProvider) GetForUsername(username string) (*ProfileResponse, error) { | ||||
| 	if !allowedUsernamesRegex.MatchString(username) { | ||||
| 		return nil, InvalidUsername | ||||
| 	} | ||||
|  | ||||
| 	username = strings.ToLower(username) | ||||
|  | ||||
| 	result, err, _ := p.group.Do(username, func() (*ProfileResponse, 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) (*ProfileResponse, error) { | ||||
| 	return nil, nil | ||||
| } | ||||
							
								
								
									
										166
									
								
								internal/mojang/provider_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								internal/mojang/provider_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| 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) (*ProfileResponse, error) { | ||||
| 	args := m.Called(uuid) | ||||
| 	var result *ProfileResponse | ||||
| 	if casted, ok := args.Get(0).(*ProfileResponse); 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 := &ProfileResponse{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) | ||||
|  | ||||
| 	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 := &ProfileResponse{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([]*ProfileResponse, 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
									
								
								internal/mojang/textures_provider.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								internal/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) (*ProfileResponse, error) | ||||
| } | ||||
|  | ||||
| func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*ProfileResponse, 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, *ProfileResponse] | ||||
| } | ||||
|  | ||||
| func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesProviderWithInMemoryCache { | ||||
| 	storage := &TexturesProviderWithInMemoryCache{ | ||||
| 		provider: provider, | ||||
| 		cache: ttlcache.New[string, *ProfileResponse]( | ||||
| 			ttlcache.WithDisableTouchOnHit[string, *ProfileResponse](), | ||||
| 			// I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error | ||||
| 		), | ||||
| 	} | ||||
|  | ||||
| 	return storage | ||||
| } | ||||
|  | ||||
| func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*ProfileResponse, 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
									
								
								internal/mojang/textures_provider_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								internal/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 = &ProfileResponse{ | ||||
| 	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) (*ProfileResponse, error) { | ||||
| 	args := m.Called(uuid, signed) | ||||
| 	var result *ProfileResponse | ||||
| 	if casted, ok := args.Get(0).(*ProfileResponse); 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
									
								
								internal/mojang/uuids_provider.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								internal/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
									
								
								internal/mojang/uuids_provider_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								internal/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)) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user