mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	[BREAKING]
Introduce universal profile entity Remove fs-based capes serving Rework management API Rework Redis storage schema Reducing amount of the bus emitter usage
This commit is contained in:
		
							
								
								
									
										122
									
								
								internal/profiles/manager.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								internal/profiles/manager.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| package profiles | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/go-playground/validator/v10" | ||||
|  | ||||
| 	"github.com/elyby/chrly/db" | ||||
| ) | ||||
|  | ||||
| type ProfilesRepository interface { | ||||
| 	FindProfileByUuid(uuid string) (*db.Profile, error) | ||||
| 	SaveProfile(profile *db.Profile) error | ||||
| 	RemoveProfileByUuid(uuid string) error | ||||
| } | ||||
|  | ||||
| func NewManager(pr ProfilesRepository) *Manager { | ||||
| 	return &Manager{ | ||||
| 		ProfilesRepository: pr, | ||||
| 		profileValidator:   createProfileValidator(), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type Manager struct { | ||||
| 	ProfilesRepository | ||||
| 	profileValidator *validator.Validate | ||||
| } | ||||
|  | ||||
| func (m *Manager) PersistProfile(profile *db.Profile) error { | ||||
| 	validationErrors := m.profileValidator.Struct(profile) | ||||
| 	if validationErrors != nil { | ||||
| 		return mapValidationErrorsToCommonError(validationErrors.(validator.ValidationErrors)) | ||||
| 	} | ||||
|  | ||||
| 	profile.Uuid = cleanupUuid(profile.Uuid) | ||||
| 	if profile.SkinUrl == "" || isClassicModel(profile.SkinModel) { | ||||
| 		profile.SkinModel = "" | ||||
| 	} | ||||
|  | ||||
| 	return m.ProfilesRepository.SaveProfile(profile) | ||||
| } | ||||
|  | ||||
| func (m *Manager) RemoveProfileByUuid(uuid string) error { | ||||
| 	return m.ProfilesRepository.RemoveProfileByUuid(cleanupUuid(uuid)) | ||||
| } | ||||
|  | ||||
| type ValidationError struct { | ||||
| 	Errors map[string][]string | ||||
| } | ||||
|  | ||||
| func (e *ValidationError) Error() string { | ||||
| 	return "The profile is invalid and cannot be persisted" | ||||
| } | ||||
|  | ||||
| func cleanupUuid(uuid string) string { | ||||
| 	return strings.ReplaceAll(strings.ToLower(uuid), "-", "") | ||||
| } | ||||
|  | ||||
| func createProfileValidator() *validator.Validate { | ||||
| 	validate := validator.New() | ||||
|  | ||||
| 	regexUuidAny := regexp.MustCompile("(?i)^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$") | ||||
| 	_ = validate.RegisterValidation("uuid_any", func(fl validator.FieldLevel) bool { | ||||
| 		return regexUuidAny.MatchString(fl.Field().String()) | ||||
| 	}) | ||||
|  | ||||
| 	regexUsername := regexp.MustCompile(`^[-\w.!$%^&*()\[\]:;]+$`) | ||||
| 	_ = validate.RegisterValidation("username", func(fl validator.FieldLevel) bool { | ||||
| 		return regexUsername.MatchString(fl.Field().String()) | ||||
| 	}) | ||||
|  | ||||
| 	validate.RegisterStructValidationMapRules(map[string]string{ | ||||
| 		"Username":        "required,username,max=21", | ||||
| 		"Uuid":            "required,uuid_any", | ||||
| 		"SkinUrl":         "omitempty,url", | ||||
| 		"SkinModel":       "omitempty,max=20", | ||||
| 		"CapeUrl":         "omitempty,url", | ||||
| 		"MojangTextures":  "omitempty,base64", | ||||
| 		"MojangSignature": "required_with=MojangTextures,omitempty,base64", | ||||
| 	}, db.Profile{}) | ||||
|  | ||||
| 	return validate | ||||
| } | ||||
|  | ||||
| func mapValidationErrorsToCommonError(err validator.ValidationErrors) *ValidationError { | ||||
| 	resultErr := &ValidationError{make(map[string][]string)} | ||||
| 	for _, e := range err { | ||||
| 		// Manager can return multiple errors per field, but the current validation implementation | ||||
| 		// returns only one error per field | ||||
| 		resultErr.Errors[e.Field()] = []string{formatValidationErr(e)} | ||||
| 	} | ||||
|  | ||||
| 	return resultErr | ||||
| } | ||||
|  | ||||
| // The go-playground/validator lib already contains tools for translated errors output. | ||||
| // However, the implementation is very heavy and becomes even more so when you need to add messages for custom validators. | ||||
| // So for simplicity, I've extracted validation error formatting into this simple implementation | ||||
| func formatValidationErr(err validator.FieldError) string { | ||||
| 	switch err.Tag() { | ||||
| 	case "required", "required_with": | ||||
| 		return fmt.Sprintf("%s is a required field", err.Field()) | ||||
| 	case "username": | ||||
| 		return fmt.Sprintf("%s must be a valid username", err.Field()) | ||||
| 	case "max": | ||||
| 		return fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param()) | ||||
| 	case "uuid_any": | ||||
| 		return fmt.Sprintf("%s must be a valid UUID", err.Field()) | ||||
| 	case "url": | ||||
| 		return fmt.Sprintf("%s must be a valid URL", err.Field()) | ||||
| 	case "base64": | ||||
| 		return fmt.Sprintf("%s must be a valid Base64 string", err.Field()) | ||||
| 	default: | ||||
| 		return fmt.Sprintf(`Field validation for "%s" failed on the "%s" tag`, err.Field(), err.Tag()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func isClassicModel(model string) bool { | ||||
| 	return model == "" || model == "classic" || model == "default" || model == "steve" | ||||
| } | ||||
							
								
								
									
										138
									
								
								internal/profiles/manager_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								internal/profiles/manager_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| package profiles | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	"github.com/stretchr/testify/suite" | ||||
|  | ||||
| 	"github.com/elyby/chrly/db" | ||||
| ) | ||||
|  | ||||
| type ProfilesRepositoryMock struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| func (m *ProfilesRepositoryMock) FindProfileByUuid(uuid string) (*db.Profile, error) { | ||||
| 	args := m.Called(uuid) | ||||
| 	var result *db.Profile | ||||
| 	if casted, ok := args.Get(0).(*db.Profile); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| func (m *ProfilesRepositoryMock) SaveProfile(profile *db.Profile) error { | ||||
| 	return m.Called(profile).Error(0) | ||||
| } | ||||
|  | ||||
| func (m *ProfilesRepositoryMock) RemoveProfileByUuid(uuid string) error { | ||||
| 	return m.Called(uuid).Error(0) | ||||
| } | ||||
|  | ||||
| type ManagerTestSuite struct { | ||||
| 	suite.Suite | ||||
|  | ||||
| 	Manager *Manager | ||||
|  | ||||
| 	ProfilesRepository *ProfilesRepositoryMock | ||||
| } | ||||
|  | ||||
| func (t *ManagerTestSuite) SetupSubTest() { | ||||
| 	t.ProfilesRepository = &ProfilesRepositoryMock{} | ||||
| 	t.Manager = NewManager(t.ProfilesRepository) | ||||
| } | ||||
|  | ||||
| func (t *ManagerTestSuite) TearDownSubTest() { | ||||
| 	t.ProfilesRepository.AssertExpectations(t.T()) | ||||
| } | ||||
|  | ||||
| func (t *ManagerTestSuite) TestPersistProfile() { | ||||
| 	t.Run("valid profile (full)", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:            "ba866a9c-c839-4268-a30f-7b26ae604c51", | ||||
| 			Username:        "mock-username", | ||||
| 			SkinUrl:         "https://example.com/skin.png", | ||||
| 			SkinModel:       "slim", | ||||
| 			CapeUrl:         "https://example.com/cape.png", | ||||
| 			MojangTextures:  "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=", | ||||
| 			MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=", | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("SaveProfile", profile).Once().Return(nil) | ||||
|  | ||||
| 		err := t.Manager.PersistProfile(profile) | ||||
| 		t.NoError(err) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("valid profile (minimal)", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "ba866a9c-c839-4268-a30f-7b26ae604c51", | ||||
| 			Username: "mock-username", | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("SaveProfile", profile).Once().Return(nil) | ||||
|  | ||||
| 		err := t.Manager.PersistProfile(profile) | ||||
| 		t.NoError(err) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("normalize uuid and skin model", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:      "BA866A9C-C839-4268-A30F-7B26AE604C51", | ||||
| 			Username:  "mock-username", | ||||
| 			SkinUrl:   "https://example.com/skin.png", | ||||
| 			SkinModel: "default", | ||||
| 		} | ||||
| 		expectedProfile := *profile | ||||
| 		expectedProfile.Uuid = "ba866a9cc8394268a30f7b26ae604c51" | ||||
| 		expectedProfile.SkinModel = "" | ||||
| 		t.ProfilesRepository.On("SaveProfile", &expectedProfile).Once().Return(nil) | ||||
|  | ||||
| 		err := t.Manager.PersistProfile(profile) | ||||
| 		t.NoError(err) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("require mojangSignature when mojangTexturesProvided", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:           "ba866a9c-c839-4268-a30f-7b26ae604c51", | ||||
| 			Username:       "mock-username", | ||||
| 			MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=", | ||||
| 		} | ||||
|  | ||||
| 		err := t.Manager.PersistProfile(profile) | ||||
| 		t.Error(err) | ||||
| 		t.IsType(&ValidationError{}, err) | ||||
| 		castedErr := err.(*ValidationError) | ||||
| 		mojangSignatureErr, mojangSignatureErrExists := castedErr.Errors["MojangSignature"] | ||||
| 		t.True(mojangSignatureErrExists) | ||||
| 		t.Contains(mojangSignatureErr[0], "required") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("validate username", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "ba866a9c-c839-4268-a30f-7b26ae604c51", | ||||
| 			Username: "invalid\"username", | ||||
| 		} | ||||
|  | ||||
| 		err := t.Manager.PersistProfile(profile) | ||||
| 		t.Error(err) | ||||
| 		t.IsType(&ValidationError{}, err) | ||||
| 		castedErr := err.(*ValidationError) | ||||
| 		usernameErrs, usernameErrExists := castedErr.Errors["Username"] | ||||
| 		t.True(usernameErrExists) | ||||
| 		t.Contains(usernameErrs[0], "valid") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("empty profile", func() { | ||||
| 		profile := &db.Profile{} | ||||
|  | ||||
| 		err := t.Manager.PersistProfile(profile) | ||||
| 		t.Error(err) | ||||
| 		t.IsType(&ValidationError{}, err) | ||||
| 		// TODO: validate errors | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestManager(t *testing.T) { | ||||
| 	suite.Run(t, new(ManagerTestSuite)) | ||||
| } | ||||
							
								
								
									
										88
									
								
								internal/profiles/provider.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								internal/profiles/provider.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| package profiles | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
|  | ||||
| 	"github.com/elyby/chrly/db" | ||||
| 	"github.com/elyby/chrly/mojang" | ||||
| ) | ||||
|  | ||||
| type ProfilesFinder interface { | ||||
| 	FindProfileByUsername(username string) (*db.Profile, error) | ||||
| } | ||||
|  | ||||
| type MojangProfilesProvider interface { | ||||
| 	GetForUsername(username string) (*mojang.ProfileResponse, error) | ||||
| } | ||||
|  | ||||
| type Provider struct { | ||||
| 	ProfilesFinder | ||||
| 	MojangProfilesProvider | ||||
| } | ||||
|  | ||||
| func (p *Provider) FindProfileByUsername(username string, allowProxy bool) (*db.Profile, error) { | ||||
| 	profile, err := p.ProfilesFinder.FindProfileByUsername(username) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if profile != nil && (profile.SkinUrl != "" || profile.CapeUrl != "") { | ||||
| 		return profile, nil | ||||
| 	} | ||||
|  | ||||
| 	if allowProxy { | ||||
| 		mojangProfile, err := p.MojangProfilesProvider.GetForUsername(username) | ||||
| 		// If we at least know something about the user, | ||||
| 		// then we can ignore an error and return profile without textures | ||||
| 		if err != nil && profile != nil { | ||||
| 			return profile, nil | ||||
| 		} | ||||
|  | ||||
| 		if err != nil || mojangProfile == nil { | ||||
| 			if errors.Is(err, mojang.InvalidUsername) { | ||||
| 				return nil, nil | ||||
| 			} | ||||
|  | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		decodedTextures, err := mojangProfile.DecodeTextures() | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		profile = &db.Profile{ | ||||
| 			Uuid:     mojangProfile.Id, | ||||
| 			Username: mojangProfile.Name, | ||||
| 		} | ||||
|  | ||||
| 		// There might be no textures property | ||||
| 		if decodedTextures != nil { | ||||
| 			if decodedTextures.Textures.Skin != nil { | ||||
| 				profile.SkinUrl = decodedTextures.Textures.Skin.Url | ||||
| 				if decodedTextures.Textures.Skin.Metadata != nil { | ||||
| 					profile.SkinModel = decodedTextures.Textures.Skin.Metadata.Model | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if decodedTextures.Textures.Cape != nil { | ||||
| 				profile.CapeUrl = decodedTextures.Textures.Cape.Url | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		var texturesProp *mojang.Property | ||||
| 		for _, prop := range mojangProfile.Props { | ||||
| 			if prop.Name == "textures" { | ||||
| 				texturesProp = prop | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if texturesProp != nil { | ||||
| 			profile.MojangTextures = texturesProp.Value | ||||
| 			profile.MojangSignature = texturesProp.Signature | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return profile, nil | ||||
| } | ||||
							
								
								
									
										272
									
								
								internal/profiles/provider_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								internal/profiles/provider_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,272 @@ | ||||
| package profiles | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	"github.com/stretchr/testify/suite" | ||||
|  | ||||
| 	"github.com/elyby/chrly/db" | ||||
| 	"github.com/elyby/chrly/mojang" | ||||
| 	"github.com/elyby/chrly/utils" | ||||
| ) | ||||
|  | ||||
| type ProfilesFinderMock struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| func (m *ProfilesFinderMock) FindProfileByUsername(username string) (*db.Profile, error) { | ||||
| 	args := m.Called(username) | ||||
| 	var result *db.Profile | ||||
| 	if casted, ok := args.Get(0).(*db.Profile); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| type MojangProfilesProviderMock struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| func (m *MojangProfilesProviderMock) GetForUsername(username string) (*mojang.ProfileResponse, error) { | ||||
| 	args := m.Called(username) | ||||
| 	var result *mojang.ProfileResponse | ||||
| 	if casted, ok := args.Get(0).(*mojang.ProfileResponse); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| type CombinedProfilesProviderSuite struct { | ||||
| 	suite.Suite | ||||
|  | ||||
| 	Provider *Provider | ||||
|  | ||||
| 	ProfilesRepository     *ProfilesFinderMock | ||||
| 	MojangProfilesProvider *MojangProfilesProviderMock | ||||
| } | ||||
|  | ||||
| func (t *CombinedProfilesProviderSuite) SetupSubTest() { | ||||
| 	t.ProfilesRepository = &ProfilesFinderMock{} | ||||
| 	t.MojangProfilesProvider = &MojangProfilesProviderMock{} | ||||
| 	t.Provider = &Provider{ | ||||
| 		ProfilesFinder:         t.ProfilesRepository, | ||||
| 		MojangProfilesProvider: t.MojangProfilesProvider, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (t *CombinedProfilesProviderSuite) TearDownSubTest() { | ||||
| 	t.ProfilesRepository.AssertExpectations(t.T()) | ||||
| 	t.MojangProfilesProvider.AssertExpectations(t.T()) | ||||
| } | ||||
|  | ||||
| func (t *CombinedProfilesProviderSuite) TestFindByUsername() { | ||||
| 	t.Run("exists profile with a skin", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "mock-uuid", | ||||
| 			Username: "Mock", | ||||
| 			SkinUrl:  "https://example.com/skin.png", | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Same(profile, foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("exists profile with a cape", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "mock-uuid", | ||||
| 			Username: "Mock", | ||||
| 			CapeUrl:  "https://example.com/cape.png", | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Same(profile, foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("exists profile without textures (no proxy)", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "mock-uuid", | ||||
| 			Username: "Mock", | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", false) | ||||
| 		t.NoError(err) | ||||
| 		t.Same(profile, foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("not exists profile (no proxy)", func() { | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", false) | ||||
| 		t.NoError(err) | ||||
| 		t.Nil(foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handle error from profiles repository", func() { | ||||
| 		expectedError := errors.New("mock error") | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, expectedError) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", false) | ||||
| 		t.Same(expectedError, err) | ||||
| 		t.Nil(foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("exists profile without textures (with proxy)", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "mock-uuid", | ||||
| 			Username: "Mock", | ||||
| 		} | ||||
| 		mojangProfile := createMojangProfile(true, true) | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Equal(&db.Profile{ | ||||
| 			Uuid:            "mock-mojang-uuid", | ||||
| 			Username:        "mOcK", | ||||
| 			SkinUrl:         "https://mojang/skin.png", | ||||
| 			SkinModel:       "slim", | ||||
| 			CapeUrl:         "https://mojang/cape.png", | ||||
| 			MojangTextures:  mojangProfile.Props[0].Value, | ||||
| 			MojangSignature: mojangProfile.Props[0].Signature, | ||||
| 		}, foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("not exists profile (with proxy)", func() { | ||||
| 		mojangProfile := createMojangProfile(true, true) | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Equal(&db.Profile{ | ||||
| 			Uuid:            "mock-mojang-uuid", | ||||
| 			Username:        "mOcK", | ||||
| 			SkinUrl:         "https://mojang/skin.png", | ||||
| 			SkinModel:       "slim", | ||||
| 			CapeUrl:         "https://mojang/cape.png", | ||||
| 			MojangTextures:  mojangProfile.Props[0].Value, | ||||
| 			MojangSignature: mojangProfile.Props[0].Signature, | ||||
| 		}, foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("should return known profile without textures when received an error from the mojang", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "mock-uuid", | ||||
| 			Username: "Mock", | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, errors.New("mock error")) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Same(profile, foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("should not return an error when passed the invalid username", func() { | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, mojang.InvalidUsername) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Nil(foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("should return an error from mojang provider", func() { | ||||
| 		expectedError := errors.New("mock error") | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, expectedError) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.Same(expectedError, err) | ||||
| 		t.Nil(foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("should correctly handle invalid textures from mojang", func() { | ||||
| 		mojangProfile := &mojang.ProfileResponse{ | ||||
| 			Props: []*mojang.Property{ | ||||
| 				{ | ||||
| 					Name:      "textures", | ||||
| 					Value:     "this is invalid base64", | ||||
| 					Signature: "mojang signature", | ||||
| 				}, | ||||
| 			}, | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.ErrorContains(err, "illegal base64 data") | ||||
| 		t.Nil(foundProfile) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("should correctly handle missing textures property from Mojang", func() { | ||||
| 		mojangProfile := &mojang.ProfileResponse{ | ||||
| 			Id:    "mock-mojang-uuid", | ||||
| 			Name:  "mOcK", | ||||
| 			Props: []*mojang.Property{}, | ||||
| 		} | ||||
| 		t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) | ||||
| 		t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) | ||||
|  | ||||
| 		foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) | ||||
| 		t.NoError(err) | ||||
| 		t.Equal(&db.Profile{ | ||||
| 			Uuid:     "mock-mojang-uuid", | ||||
| 			Username: "mOcK", | ||||
| 		}, foundProfile) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestProvider(t *testing.T) { | ||||
| 	suite.Run(t, new(CombinedProfilesProviderSuite)) | ||||
| } | ||||
|  | ||||
| func createMojangProfile(withSkin bool, withCape bool) *mojang.ProfileResponse { | ||||
| 	timeZone, _ := time.LoadLocation("Europe/Warsaw") | ||||
| 	textures := &mojang.TexturesProp{ | ||||
| 		Timestamp:   utils.UnixMillisecond(time.Date(2024, 1, 29, 13, 34, 12, 0, timeZone)), | ||||
| 		ProfileID:   "mock-mojang-uuid", | ||||
| 		ProfileName: "mOcK", | ||||
| 		Textures:    &mojang.TexturesResponse{}, | ||||
| 	} | ||||
|  | ||||
| 	if withSkin { | ||||
| 		textures.Textures.Skin = &mojang.SkinTexturesResponse{ | ||||
| 			Url: "https://mojang/skin.png", | ||||
| 			Metadata: &mojang.SkinTexturesMetadata{ | ||||
| 				Model: "slim", | ||||
| 			}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if withCape { | ||||
| 		textures.Textures.Cape = &mojang.CapeTexturesResponse{ | ||||
| 			Url: "https://mojang/cape.png", | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	response := &mojang.ProfileResponse{ | ||||
| 		Id:   textures.ProfileID, | ||||
| 		Name: textures.ProfileName, | ||||
| 		Props: []*mojang.Property{ | ||||
| 			{ | ||||
| 				Name:      "textures", | ||||
| 				Value:     mojang.EncodeTextures(textures), | ||||
| 				Signature: "mojang signature", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	return response | ||||
| } | ||||
		Reference in New Issue
	
	Block a user