Rework project's structure

This commit is contained in:
ErickSkrauch
2024-02-01 07:58:26 +01:00
parent dac3ca9001
commit 77e466cc0d
69 changed files with 130 additions and 161 deletions

View 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)
}
}

View 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
View 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)
}

View 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)
})
}
}

View 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
}

View 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)
}

View 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()
})
}

View 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))
}

View 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
}

View 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))
}