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

232
internal/db/redis/redis.go Normal file
View File

@@ -0,0 +1,232 @@
package redis
import (
"context"
"fmt"
"strings"
"github.com/mediocregopher/radix/v4"
"github.com/elyby/chrly/internal/db"
)
const usernameToProfileKey = "hash:username-to-profile"
const userUuidToUsernameKey = "hash:uuid-to-username"
type Redis struct {
client radix.Client
context context.Context
serializer db.ProfileSerializer
}
func New(ctx context.Context, profileSerializer db.ProfileSerializer, addr string, poolSize int) (*Redis, error) {
client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr)
if err != nil {
return nil, err
}
return &Redis{
client: client,
context: ctx,
serializer: profileSerializer,
}, nil
}
func (r *Redis) FindProfileByUsername(username string) (*db.Profile, error) {
var profile *db.Profile
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
profile, err = r.findProfileByUsername(ctx, conn, username)
return err
}))
return profile, err
}
func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) {
var encodedResult []byte
err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username)))
if err != nil {
return nil, err
}
if len(encodedResult) == 0 {
return nil, nil
}
return r.serializer.Deserialize(encodedResult)
}
func (r *Redis) FindProfileByUuid(uuid string) (*db.Profile, error) {
var skin *db.Profile
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
skin, err = r.findProfileByUuid(ctx, conn, uuid)
return err
}))
return skin, err
}
func (r *Redis) findProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) (*db.Profile, error) {
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
if err != nil {
return nil, err
}
if username == "" {
return nil, nil
}
return r.findProfileByUsername(ctx, conn, username)
}
func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) {
var username string
return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid)))
}
func (r *Redis) SaveProfile(profile *db.Profile) error {
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return r.saveProfile(ctx, conn, profile)
}))
}
func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error {
newUsernameHashKey := usernameHashKey(profile.Username)
existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid)
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
// If user has changed username, then we must delete his old username record
if existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey))
if err != nil {
return err
}
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey))
if err != nil {
return err
}
serializedProfile, err := r.serializer.Serialize(profile)
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile))
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "EXEC"))
if err != nil {
return err
}
return nil
}
func (r *Redis) RemoveProfileByUuid(uuid string) error {
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return r.removeProfileByUuid(ctx, conn, uuid)
}))
}
func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error {
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", userUuidToUsernameKey, normalizeUuid(uuid)))
if err != nil {
return err
}
if username != "" {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username)))
if err != nil {
return err
}
}
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
}
func (r *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
var uuid string
foundUsername := username
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
return err
}))
return uuid, foundUsername, err
}
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
key := buildMojangUsernameKey(username)
var result string
err := conn.Do(ctx, radix.Cmd(&result, "GET", key))
if err != nil {
return "", "", err
}
if result == "" {
return "", "", nil
}
parts := strings.Split(result, ":")
return parts[1], parts[0], nil
}
func (r *Redis) StoreMojangUuid(username string, uuid string) error {
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return storeMojangUuid(ctx, conn, username, uuid)
}))
}
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
value := fmt.Sprintf("%s:%s", username, uuid)
err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30))
if err != nil {
return err
}
return nil
}
func (r *Redis) Ping() error {
return r.client.Do(r.context, radix.Cmd(nil, "PING"))
}
func normalizeUuid(uuid string) string {
return strings.ToLower(strings.ReplaceAll(uuid, "-", ""))
}
func usernameHashKey(username string) string {
return strings.ToLower(username)
}
func buildMojangUsernameKey(username string) string {
return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username))
}

View File

@@ -0,0 +1,299 @@
//go:build redis
package redis
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"testing"
"github.com/mediocregopher/radix/v4"
"github.com/stretchr/testify/mock"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/internal/db"
)
var redisAddr string
func init() {
host := "localhost"
port := 6379
if os.Getenv("STORAGE_REDIS_HOST") != "" {
host = os.Getenv("STORAGE_REDIS_HOST")
}
if os.Getenv("STORAGE_REDIS_PORT") != "" {
port, _ = strconv.Atoi(os.Getenv("STORAGE_REDIS_PORT"))
}
redisAddr = fmt.Sprintf("%s:%d", host, port)
}
type MockProfileSerializer struct {
mock.Mock
}
func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) {
args := m.Called(profile)
return []byte(args.String(0)), args.Error(1)
}
func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) {
args := m.Called(value)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
})
t.Run("should return error", func(t *testing.T) {
conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution
assert.Error(t, err)
assert.Nil(t, conn)
})
}
type redisTestSuite struct {
suite.Suite
Redis *Redis
Serializer *MockProfileSerializer
cmd func(cmd string, args ...interface{}) string
}
func (s *redisTestSuite) SetupSuite() {
s.Serializer = &MockProfileSerializer{}
ctx := context.Background()
conn, err := New(ctx, s.Serializer, redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
s.Redis = conn
s.cmd = func(cmd string, args ...interface{}) string {
var result string
err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
if err != nil {
panic(err)
}
return result
}
}
func (s *redisTestSuite) SetupSubTest() {
// Cleanup database before each test
s.cmd("FLUSHALL")
}
func (s *redisTestSuite) TearDownSubTest() {
s.Serializer.AssertExpectations(s.T())
for _, call := range s.Serializer.ExpectedCalls {
call.Unset()
}
}
func TestRedis(t *testing.T) {
suite.Run(t, new(redisTestSuite))
}
func (s *redisTestSuite) TestFindProfileByUsername() {
s.Run("exists record", func() {
serializedData := []byte("mock.exists.profile")
expectedProfile := &db.Profile{}
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
profile, err := s.Redis.FindProfileByUsername("Mock")
s.Require().NoError(err)
s.Require().Same(expectedProfile, profile)
})
s.Run("not exists record", func() {
profile, err := s.Redis.FindProfileByUsername("Mock")
s.Require().NoError(err)
s.Require().Nil(profile)
})
s.Run("an error from serializer implementation", func() {
expectedError := errors.New("mock error")
s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data")
s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError)
profile, err := s.Redis.FindProfileByUsername("Mock")
s.Require().Nil(profile)
s.Require().ErrorIs(err, expectedError)
})
}
func (s *redisTestSuite) TestFindProfileByUuid() {
s.Run("exists record", func() {
serializedData := []byte("mock.exists.profile")
expectedProfile := &db.Profile{Username: "Mock"}
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
s.Require().Same(expectedProfile, profile)
})
s.Run("not exists record", func() {
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
s.Require().Nil(profile)
})
s.Run("exists uuid record, but related profile not exists", func() {
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
s.Require().Nil(profile)
})
}
func (s *redisTestSuite) TestSaveProfile() {
s.Run("save new entity", func() {
profile := &db.Profile{
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
Username: "Mock",
}
serializedProfile := "serialized-profile"
s.Serializer.On("Serialize", profile).Return(serializedProfile, nil)
s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile)
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.SaveProfile(profile)
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Equal("mock", uuidResp)
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Equal(serializedProfile, profileResp)
})
s.Run("update exists record with changed username", func() {
newProfile := &db.Profile{
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
Username: "NewMock",
}
serializedNewProfile := "serialized-new-profile"
s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil)
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile")
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.SaveProfile(newProfile)
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Equal("newmock", uuidResp)
newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock")
s.Require().Equal(serializedNewProfile, newProfileResp)
oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Empty(oldProfileResp)
})
}
func (s *redisTestSuite) TestRemoveProfileByUuid() {
s.Run("exists record", func() {
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile")
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Empty(uuidResp)
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Empty(profileResp)
})
s.Run("uuid exists, username is missing", func() {
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Empty(uuidResp)
})
s.Run("uuid not exists", func() {
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
})
}
func (s *redisTestSuite) TestGetUuidForMojangUsername() {
s.Run("exists record", func() {
s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
s.Require().NoError(err)
s.Require().Equal("MoCk", username)
s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
s.Run("exists record with empty uuid value", func() {
s.cmd("SET", "mojang:uuid:mock", "MoCk:")
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
s.Require().NoError(err)
s.Require().Equal("MoCk", username)
s.Require().Empty(uuid)
})
s.Run("not exists record", func() {
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
s.Require().NoError(err)
s.Require().Empty(username)
s.Require().Empty(uuid)
})
}
func (s *redisTestSuite) TestStoreUuid() {
s.Run("store uuid", func() {
err := s.Redis.StoreMojangUuid("MoCk", "d3ca513eb3e14946b58047f2bd3530fd")
s.Require().NoError(err)
resp := s.cmd("GET", "mojang:uuid:mock")
s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
})
s.Run("store empty uuid", func() {
err := s.Redis.StoreMojangUuid("MoCk", "")
s.Require().NoError(err)
resp := s.cmd("GET", "mojang:uuid:mock")
s.Require().Equal(resp, "MoCk:")
})
}
func (s *redisTestSuite) TestPing() {
err := s.Redis.Ping()
s.Require().Nil(err)
}