feat: player sync (#21)

Reviewed-on: twhelp/corev3#21
This commit is contained in:
Dawid Wysokiński 2024-01-01 10:20:30 +00:00
parent 1b699d225f
commit 751805d6b1
20 changed files with 8634 additions and 50 deletions

View File

@ -135,7 +135,7 @@ var cmdConsumer = &cli.Command{
}
consumer := port.NewPlayerWatermillConsumer(
app.NewPlayerService(twSvc),
app.NewPlayerService(adapter.NewPlayerBunRepository(db), twSvc),
subscriber,
logger,
marshaler,

View File

@ -20,6 +20,7 @@ func NewFixture(bunDB *bun.DB) *Fixture {
(*bunmodel.Version)(nil),
(*bunmodel.Server)(nil),
(*bunmodel.Tribe)(nil),
(*bunmodel.Player)(nil),
)
return &Fixture{
f: dbfixture.New(bunDB),

View File

@ -0,0 +1,95 @@
package bunmodel
import (
"fmt"
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"github.com/uptrace/bun"
)
type Player struct {
bun.BaseModel `bun:"table:players,alias:player"`
ID int `bun:"id,nullzero,pk"`
ServerKey string `bun:"server_key,nullzero,pk"`
Server Server `bun:"server,rel:belongs-to,join:server_key=key"`
Name string `bun:"name,nullzero"`
NumVillages int `bun:"num_villages"`
Points int `bun:"points"`
Rank int `bun:"rank"`
TribeID int `bun:"tribe_id,nullzero"`
Tribe Tribe `bun:"tribe,rel:belongs-to,join:tribe_id=id,join:server_key=server_key"`
ProfileURL string `bun:"profile_url,nullzero"`
BestRank int `bun:"best_rank"`
BestRankAt time.Time `bun:"best_rank_at,nullzero,default:CURRENT_TIMESTAMP"`
MostPoints int `bun:"most_points"`
MostPointsAt time.Time `bun:"most_points_at,nullzero,default:CURRENT_TIMESTAMP"`
MostVillages int `bun:"most_villages"`
MostVillagesAt time.Time `bun:"most_villages_at,nullzero,default:CURRENT_TIMESTAMP"`
LastActivityAt time.Time `bun:"last_activity_at,nullzero,default:CURRENT_TIMESTAMP"`
CreatedAt time.Time `bun:"created_at,nullzero,default:CURRENT_TIMESTAMP"`
DeletedAt time.Time `bun:"deleted_at,nullzero"`
OpponentsDefeated
}
func (p Player) ToDomain() (domain.Player, error) {
od, err := p.OpponentsDefeated.ToDomain()
if err != nil {
return domain.Player{}, fmt.Errorf(
"couldn't construct domain.Player (id=%d,serverKey=%s): %w",
p.ID,
p.ServerKey,
err,
)
}
converted, err := domain.UnmarshalPlayerFromDatabase(
p.ID,
p.ServerKey,
p.Name,
p.NumVillages,
p.Points,
p.Rank,
p.TribeID,
od,
p.ProfileURL,
p.BestRank,
p.BestRankAt,
p.MostPoints,
p.MostPointsAt,
p.MostVillages,
p.MostVillagesAt,
p.LastActivityAt,
p.CreatedAt,
p.DeletedAt,
)
if err != nil {
return domain.Player{}, fmt.Errorf(
"couldn't construct domain.Player (id=%d,serverKey=%s): %w",
p.ID,
p.ServerKey,
err,
)
}
return converted, nil
}
type Players []Player
func (ps Players) ToDomain() (domain.Players, error) {
res := make(domain.Players, 0, len(ps))
for _, p := range ps {
converted, err := p.ToDomain()
if err != nil {
return nil, err
}
res = append(res, converted)
}
return res, nil
}

View File

@ -0,0 +1,166 @@
package adapter
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/adapter/internal/bunmodel"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
type PlayerBunRepository struct {
db bun.IDB
}
func NewPlayerBunRepository(db bun.IDB) *PlayerBunRepository {
return &PlayerBunRepository{db: db}
}
func (repo *PlayerBunRepository) CreateOrUpdate(ctx context.Context, params ...domain.CreatePlayerParams) error {
if len(params) == 0 {
return nil
}
players := make(bunmodel.Players, 0, len(params))
for _, p := range params {
base := p.Base()
players = append(players, bunmodel.Player{
ID: base.ID(),
ServerKey: p.ServerKey(),
Name: base.Name(),
NumVillages: base.NumVillages(),
Points: base.Points(),
Rank: base.Rank(),
TribeID: base.TribeID(),
ProfileURL: base.ProfileURL().String(),
BestRank: p.BestRank(),
BestRankAt: p.BestRankAt(),
MostPoints: p.MostPoints(),
MostPointsAt: p.MostPointsAt(),
MostVillages: p.MostVillages(),
MostVillagesAt: p.MostVillagesAt(),
LastActivityAt: p.LastActivityAt(),
CreatedAt: time.Now(),
OpponentsDefeated: bunmodel.NewOpponentsDefeated(base.OD()),
})
}
q := repo.db.NewInsert().
Model(&players)
//nolint:exhaustive
switch q.Dialect().Name() {
case dialect.PG:
q = q.On("CONFLICT ON CONSTRAINT players_pkey DO UPDATE")
case dialect.SQLite:
q = q.On("CONFLICT(id, server_key) DO UPDATE")
default:
q = q.Err(errors.New("unsupported dialect"))
}
if _, err := q.
Set("name = EXCLUDED.name").
Set("num_villages = EXCLUDED.num_villages").
Set("points = EXCLUDED.points").
Set("rank = EXCLUDED.rank").
Set("tribe_id = EXCLUDED.tribe_id").
Set("profile_url = EXCLUDED.profile_url").
Set("best_rank = EXCLUDED.best_rank").
Set("best_rank_at = EXCLUDED.best_rank_at").
Set("most_villages = EXCLUDED.most_villages").
Set("most_villages_at = EXCLUDED.most_villages_at").
Set("most_points = EXCLUDED.most_points").
Set("most_points_at = EXCLUDED.most_points_at").
Set("last_activity_at = EXCLUDED.last_activity_at").
Set("deleted_at = EXCLUDED.deleted_at").
Apply(appendODSetClauses).
Returning("").
Exec(ctx); err != nil {
return fmt.Errorf("something went wrong while inserting players into the db: %w", err)
}
return nil
}
func (repo *PlayerBunRepository) List(ctx context.Context, params domain.ListPlayersParams) (domain.Players, error) {
var players bunmodel.Players
if err := repo.db.NewSelect().
Model(&players).
Apply(listPlayersParamsApplier{params: params}.apply).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("couldn't select players from the db: %w", err)
}
return players.ToDomain()
}
func (repo *PlayerBunRepository) Delete(ctx context.Context, serverKey string, ids ...int) error {
if len(ids) == 0 {
return nil
}
if _, err := repo.db.NewUpdate().
Model((*bunmodel.Player)(nil)).
Where("deleted_at IS NULL").
Where("id IN (?)", bun.In(ids)).
Where("server_key = ?", serverKey).
Set("deleted_at = ?", time.Now()).
Set("tribe_id = NULL").
Returning("").
Exec(ctx); err != nil {
return fmt.Errorf("couldn't delete players: %w", err)
}
return nil
}
type listPlayersParamsApplier struct {
params domain.ListPlayersParams
}
//nolint:gocyclo
func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
if ids := a.params.IDs(); len(ids) > 0 {
q = q.Where("player.id IN (?)", bun.In(ids))
}
if idGT := a.params.IDGT(); idGT.Valid {
q = q.Where("player.id > ?", idGT.Value)
}
if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 {
q = q.Where("player.server_key IN (?)", bun.In(serverKeys))
}
if deleted := a.params.Deleted(); deleted.Valid {
if deleted.Value {
q = q.Where("player.deleted_at IS NOT NULL")
} else {
q = q.Where("player.deleted_at IS NULL")
}
}
for _, s := range a.params.Sort() {
switch s {
case domain.PlayerSortIDASC:
q = q.Order("player.id ASC")
case domain.PlayerSortIDDESC:
q = q.Order("player.id DESC")
case domain.PlayerSortServerKeyASC:
q = q.Order("player.server_key ASC")
case domain.PlayerSortServerKeyDESC:
q = q.Order("player.server_key DESC")
default:
return q.Err(errors.New("unsupported sort value"))
}
}
return q.Limit(a.params.Limit()).Offset(a.params.Offset())
}

View File

@ -0,0 +1,29 @@
package adapter_test
import (
"testing"
"gitea.dwysokinski.me/twhelp/corev3/internal/adapter/adaptertest"
)
func TestPlayerBunRepository_Postgres(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
testPlayerRepository(t, func(t *testing.T) repositories {
t.Helper()
return newBunDBRepositories(t, postgres.NewBunDB(t))
})
}
func TestPlayerBunRepository_SQLite(t *testing.T) {
t.Parallel()
testPlayerRepository(t, func(t *testing.T) repositories {
t.Helper()
return newBunDBRepositories(t, adaptertest.NewBunDBSQLite(t))
})
}

View File

@ -91,16 +91,16 @@ func (repo *TribeBunRepository) CreateOrUpdate(ctx context.Context, params ...do
}
func (repo *TribeBunRepository) List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) {
var servers bunmodel.Tribes
var tribes bunmodel.Tribes
if err := repo.db.NewSelect().
Model(&servers).
Model(&tribes).
Apply(listTribesParamsApplier{params: params}.apply).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("couldn't select tribes from the db: %w", err)
}
return servers.ToDomain()
return tribes.ToDomain()
}
func (repo *TribeBunRepository) Delete(ctx context.Context, serverKey string, ids ...int) error {

View File

@ -0,0 +1,392 @@
package adapter_test
import (
"cmp"
"context"
"fmt"
"math"
"slices"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testPlayerRepository(t *testing.T, newRepos func(t *testing.T) repositories) {
t.Helper()
ctx := context.Background()
t.Run("CreateOrUpdate", func(t *testing.T) {
t.Parallel()
repos := newRepos(t)
assertCreatedUpdated := func(t *testing.T, params []domain.CreatePlayerParams) {
t.Helper()
require.NotEmpty(t, params)
ids := make([]int, 0, len(params))
for _, p := range params {
ids = append(ids, p.Base().ID())
}
listParams := domain.NewListPlayersParams()
require.NoError(t, listParams.SetIDs(ids))
require.NoError(t, listParams.SetServerKeys([]string{params[0].ServerKey()}))
players, err := repos.player.List(ctx, listParams)
require.NoError(t, err)
assert.Len(t, players, len(params))
for i, p := range params {
idx := slices.IndexFunc(players, func(player domain.Player) bool {
return player.ID() == p.Base().ID() && player.ServerKey() == p.ServerKey()
})
require.GreaterOrEqualf(t, idx, 0, "params[%d]", i)
player := players[idx]
assert.Equalf(t, p.Base(), player.Base(), "params[%d]", i)
assert.Equalf(t, p.ServerKey(), player.ServerKey(), "params[%d]", i)
assert.Equalf(t, p.BestRank(), player.BestRank(), "params[%d]", i)
assert.WithinDurationf(t, p.BestRankAt(), player.BestRankAt(), time.Minute, "params[%d]", i)
assert.Equalf(t, p.MostVillages(), player.MostVillages(), "params[%d]", i)
assert.WithinDurationf(t, p.MostVillagesAt(), player.MostVillagesAt(), time.Minute, "params[%d]", i)
assert.Equalf(t, p.MostPoints(), player.MostPoints(), "params[%d]", i)
assert.WithinDurationf(t, p.MostPointsAt(), player.MostPointsAt(), time.Minute, "params[%d]", i)
assert.WithinDurationf(t, p.LastActivityAt(), player.LastActivityAt(), time.Minute, "params[%d]", i)
}
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
servers, err := repos.server.List(ctx, domain.NewListServersParams())
require.NoError(t, err)
require.NotEmpty(t, servers)
server := servers[0]
playersToCreate := domain.BasePlayers{
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
}
createParams, err := domain.NewCreatePlayerParams(server.Key(), playersToCreate, nil)
require.NoError(t, err)
require.NoError(t, repos.player.CreateOrUpdate(ctx, createParams...))
assertCreatedUpdated(t, createParams)
playersToUpdate := domain.BasePlayers{
domaintest.NewBasePlayer(t, func(cfg *domaintest.BasePlayerConfig) {
cfg.ID = playersToCreate[0].ID()
}),
}
updateParams, err := domain.NewCreatePlayerParams(server.Key(), playersToUpdate, nil)
require.NoError(t, err)
require.NoError(t, repos.player.CreateOrUpdate(ctx, updateParams...))
assertCreatedUpdated(t, updateParams)
})
t.Run("OK: len(params) == 0", func(t *testing.T) {
t.Parallel()
require.NoError(t, repos.player.CreateOrUpdate(ctx))
})
})
t.Run("List & ListCount", func(t *testing.T) {
t.Parallel()
repos := newRepos(t)
players, listPlayersErr := repos.player.List(ctx, domain.NewListPlayersParams())
require.NoError(t, listPlayersErr)
require.NotEmpty(t, players)
randPlayer := players[0]
tests := []struct {
name string
params func(t *testing.T) domain.ListPlayersParams
assertPlayers func(t *testing.T, params domain.ListPlayersParams, players domain.Players)
assertError func(t *testing.T, err error)
assertTotal func(t *testing.T, params domain.ListPlayersParams, total int)
}{
{
name: "OK: default params",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
return domain.NewListPlayersParams()
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
if x := cmp.Compare(a.ServerKey(), b.ServerKey()); x != 0 {
return x
}
return cmp.Compare(a.ID(), b.ID())
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: "OK: sort=[serverKey DESC, id DESC]",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetSort([]domain.PlayerSort{domain.PlayerSortServerKeyDESC, domain.PlayerSortIDDESC}))
return params
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
assert.NotEmpty(t, len(players))
assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int {
if x := cmp.Compare(a.ServerKey(), b.ServerKey()) * -1; x != 0 {
return x
}
return cmp.Compare(a.ID(), b.ID()) * -1
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: fmt.Sprintf("OK: ids=[%d] serverKeys=[%s]", randPlayer.ID(), randPlayer.ServerKey()),
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetIDs([]int{randPlayer.ID()}))
require.NoError(t, params.SetServerKeys([]string{randPlayer.ServerKey()}))
return params
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
ids := params.IDs()
serverKeys := params.ServerKeys()
for _, p := range players {
assert.True(t, slices.Contains(ids, p.ID()))
assert.True(t, slices.Contains(serverKeys, p.ServerKey()))
}
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: fmt.Sprintf("OK: idGT=%d", randPlayer.ID()),
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetIDGT(domain.NullInt{
Value: randPlayer.ID(),
Valid: true,
}))
return params
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
assert.NotEmpty(t, players)
for _, p := range players {
assert.Greater(t, p.ID(), params.IDGT().Value, p.ID())
}
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: "OK: deleted=true",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetDeleted(domain.NullBool{
Value: true,
Valid: true,
}))
return params
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
assert.NotEmpty(t, players)
for _, s := range players {
assert.True(t, s.IsDeleted())
}
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: "OK: deleted=false",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetDeleted(domain.NullBool{
Value: false,
Valid: true,
}))
return params
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
assert.NotEmpty(t, players)
for _, s := range players {
assert.False(t, s.IsDeleted())
}
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: "OK: offset=1 limit=2",
params: func(t *testing.T) domain.ListPlayersParams {
t.Helper()
params := domain.NewListPlayersParams()
require.NoError(t, params.SetOffset(1))
require.NoError(t, params.SetLimit(2))
return params
},
assertPlayers: func(t *testing.T, params domain.ListPlayersParams, players domain.Players) {
t.Helper()
assert.Len(t, players, params.Limit())
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListPlayersParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := tt.params(t)
res, err := repos.player.List(ctx, params)
tt.assertError(t, err)
tt.assertPlayers(t, params, res)
})
}
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
repos := newRepos(t)
listServersParams := domain.NewListServersParams()
require.NoError(t, listServersParams.SetSpecial(domain.NullBool{Value: false, Valid: true}))
servers, listServersErr := repos.server.List(ctx, listServersParams)
require.NoError(t, listServersErr)
require.NotEmpty(t, servers)
t.Run("OK", func(t *testing.T) {
t.Parallel()
serverKeys := make([]string, 0, len(servers))
for _, s := range servers {
serverKeys = append(serverKeys, s.Key())
}
listPlayersParams := domain.NewListPlayersParams()
require.NoError(t, listPlayersParams.SetDeleted(domain.NullBool{Value: false, Valid: true}))
require.NoError(t, listPlayersParams.SetServerKeys(serverKeys))
players, err := repos.player.List(ctx, listPlayersParams)
require.NoError(t, err)
var serverKey string
var ids []int
for _, p := range players {
if serverKey == "" {
serverKey = p.ServerKey()
}
if p.ServerKey() == serverKey {
ids = append(ids, p.ID())
}
}
idsToDelete := ids[:int(math.Ceil(float64(len(ids))/2))]
require.NoError(t, repos.player.Delete(ctx, serverKey, idsToDelete...))
listPlayersParams = domain.NewListPlayersParams()
require.NoError(t, listPlayersParams.SetDeleted(domain.NullBool{Valid: false}))
require.NoError(t, listPlayersParams.SetServerKeys(serverKeys))
players, err = repos.player.List(ctx, listPlayersParams)
require.NoError(t, err)
for _, p := range players {
if p.ServerKey() == serverKey && slices.Contains(ids, p.ID()) {
if slices.Contains(idsToDelete, p.ID()) {
assert.WithinDuration(t, time.Now(), p.DeletedAt(), time.Minute)
assert.Zero(t, p.TribeID())
} else {
assert.Zero(t, p.DeletedAt())
}
continue
}
// ensure that no other player is removed
assert.WithinRange(t, p.DeletedAt(), time.Time{}, time.Now().Add(-time.Minute))
}
})
t.Run("OK: len(ids) == 0", func(t *testing.T) {
t.Parallel()
require.NoError(t, repos.player.Delete(ctx, servers[0].Key()))
})
})
}

View File

@ -31,10 +31,17 @@ type tribeRepository interface {
Delete(ctx context.Context, serverKey string, ids ...int) error
}
type playerRepository interface {
CreateOrUpdate(ctx context.Context, params ...domain.CreatePlayerParams) error
List(ctx context.Context, params domain.ListPlayersParams) (domain.Players, error)
Delete(ctx context.Context, serverKey string, ids ...int) error
}
type repositories struct {
version versionRepository
server serverRepository
tribe tribeRepository
player playerRepository
}
func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories {
@ -46,5 +53,6 @@ func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories {
version: adapter.NewVersionBunRepository(bunDB),
server: adapter.NewServerBunRepository(bunDB),
tribe: adapter.NewTribeBunRepository(bunDB),
player: adapter.NewPlayerBunRepository(bunDB),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,21 +7,134 @@ import (
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
)
type PlayerRepository interface {
CreateOrUpdate(ctx context.Context, params ...domain.CreatePlayerParams) error
List(ctx context.Context, params domain.ListPlayersParams) (domain.Players, error)
// Delete marks players with the given serverKey and ids as deleted (sets deleted at to now).
// In addition, Delete sets TribeID to null.
//
// https://en.wiktionary.org/wiki/soft_deletion
Delete(ctx context.Context, serverKey string, ids ...int) error
}
type PlayerService struct {
repo PlayerRepository
twSvc TWService
}
func NewPlayerService(twSvc TWService) *PlayerService {
return &PlayerService{twSvc: twSvc}
func NewPlayerService(repo PlayerRepository, twSvc TWService) *PlayerService {
return &PlayerService{repo: repo, twSvc: twSvc}
}
func (svc *PlayerService) Sync(ctx context.Context, payload domain.ServerSyncedEventPayload) error {
tribes, err := svc.twSvc.GetPlayers(ctx, payload.URL())
func (svc *PlayerService) Sync(ctx context.Context, serverSyncedPayload domain.ServerSyncedEventPayload) error {
serverKey := serverSyncedPayload.Key()
serverURL := serverSyncedPayload.URL()
players, err := svc.twSvc.GetPlayers(ctx, serverURL)
if err != nil {
return fmt.Errorf("%s: couldn't get players: %w", payload.Key(), err)
return fmt.Errorf("%s: couldn't get players: %w", serverKey, err)
}
fmt.Println(payload.URL(), len(tribes))
if err = svc.createOrUpdate(ctx, serverKey, players); err != nil {
return fmt.Errorf("%s: couldn't create/update players: %w", serverKey, err)
}
if err = svc.delete(ctx, serverKey, players); err != nil {
return fmt.Errorf("%s: couldn't delete players: %w", serverKey, err)
}
return nil
}
const playerCreateOrUpdateChunkSize = domain.PlayerListMaxLimit
func (svc *PlayerService) createOrUpdate(ctx context.Context, serverKey string, players domain.BasePlayers) error {
for i := 0; i < len(players); i += playerCreateOrUpdateChunkSize {
end := i + playerCreateOrUpdateChunkSize
if end > len(players) {
end = len(players)
}
if err := svc.createOrUpdateChunk(ctx, serverKey, players[i:end]); err != nil {
return err
}
}
return nil
}
func (svc *PlayerService) createOrUpdateChunk(ctx context.Context, serverKey string, players domain.BasePlayers) error {
ids := make([]int, 0, len(players))
for _, p := range players {
ids = append(ids, p.ID())
}
listParams := domain.NewListPlayersParams()
if err := listParams.SetServerKeys([]string{serverKey}); err != nil {
return err
}
if err := listParams.SetIDs(ids); err != nil {
return err
}
if err := listParams.SetSort([]domain.PlayerSort{domain.PlayerSortIDASC}); err != nil {
return err
}
if err := listParams.SetLimit(domain.PlayerListMaxLimit); err != nil {
return err
}
storedPlayers, err := svc.repo.List(ctx, listParams)
if err != nil {
return err
}
createParams, err := domain.NewCreatePlayerParams(serverKey, players, storedPlayers)
if err != nil {
return err
}
return svc.repo.CreateOrUpdate(ctx, createParams...)
}
func (svc *PlayerService) delete(ctx context.Context, serverKey string, players domain.BasePlayers) error {
listParams := domain.NewListPlayersParams()
if err := listParams.SetServerKeys([]string{serverKey}); err != nil {
return err
}
if err := listParams.SetDeleted(domain.NullBool{
Value: false,
Valid: true,
}); err != nil {
return err
}
if err := listParams.SetSort([]domain.PlayerSort{domain.PlayerSortIDASC}); err != nil {
return err
}
if err := listParams.SetLimit(domain.PlayerListMaxLimit); err != nil {
return err
}
var toDelete []int
for {
storedPlayers, err := svc.repo.List(ctx, listParams)
if err != nil {
return err
}
if len(storedPlayers) == 0 {
break
}
toDelete = append(toDelete, storedPlayers.Delete(players)...)
if err = listParams.SetIDGT(domain.NullInt{
Value: storedPlayers[len(storedPlayers)-1].ID(),
Valid: true,
}); err != nil {
return err
}
}
return svc.repo.Delete(ctx, serverKey, toDelete...)
}

View File

@ -10,6 +10,9 @@ import (
type TribeRepository interface {
CreateOrUpdate(ctx context.Context, params ...domain.CreateTribeParams) error
List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error)
// Delete marks players with the given serverKey and ids as deleted (sets deleted at to now).
//
// https://en.wiktionary.org/wiki/soft_deletion
Delete(ctx context.Context, serverKey string, ids ...int) error
}

View File

@ -128,4 +128,8 @@ func (p BasePlayer) ProfileURL() *url.URL {
return p.profileURL
}
func (p BasePlayer) IsZero() bool {
return p == BasePlayer{}
}
type BasePlayers []BasePlayer

View File

@ -6,11 +6,24 @@ import (
"github.com/stretchr/testify/require"
)
func NewOpponentsDefeated(tb TestingTB) domain.OpponentsDefeated {
type OpponentsDefeatedConfig struct {
ScoreAtt int
}
func NewOpponentsDefeated(tb TestingTB, opts ...func(cfg *OpponentsDefeatedConfig)) domain.OpponentsDefeated {
tb.Helper()
cfg, err := domain.NewOpponentsDefeated(
cfg := &OpponentsDefeatedConfig{
ScoreAtt: gofakeit.IntRange(100000, 1000000),
}
for _, opt := range opts {
opt(cfg)
}
od, err := domain.NewOpponentsDefeated(
gofakeit.IntRange(1, 100),
gofakeit.IntRange(100000, 1000000),
cfg.ScoreAtt,
gofakeit.IntRange(1, 100),
gofakeit.IntRange(100000, 1000000),
gofakeit.IntRange(1, 100),
@ -19,5 +32,6 @@ func NewOpponentsDefeated(tb TestingTB) domain.OpponentsDefeated {
gofakeit.IntRange(100000, 1000000),
)
require.NoError(tb, err)
return cfg
return od
}

View File

@ -0,0 +1,73 @@
package domaintest
import (
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/require"
)
type PlayerConfig struct {
ID int
Points int
NumVillages int
ServerKey string
OD domain.OpponentsDefeated
BestRank int
BestRankAt time.Time
MostPoints int
MostPointsAt time.Time
MostVillages int
MostVillagesAt time.Time
LastActivityAt time.Time
}
func NewPlayer(tb TestingTB, opts ...func(cfg *PlayerConfig)) domain.Player {
tb.Helper()
now := time.Now()
cfg := &PlayerConfig{
ID: RandID(),
ServerKey: RandServerKey(),
Points: gofakeit.IntRange(1, 10000),
NumVillages: gofakeit.IntRange(1, 10000),
OD: NewOpponentsDefeated(tb),
BestRank: gofakeit.IntRange(1, 10000),
BestRankAt: now,
MostPoints: gofakeit.IntRange(1, 10000),
MostPointsAt: now,
MostVillages: gofakeit.IntRange(1, 10000),
MostVillagesAt: now,
LastActivityAt: now,
}
for _, opt := range opts {
opt(cfg)
}
p, err := domain.UnmarshalPlayerFromDatabase(
cfg.ID,
cfg.ServerKey,
gofakeit.LetterN(50),
cfg.NumVillages,
cfg.Points,
gofakeit.IntRange(1, 10000),
gofakeit.IntRange(1, 10000),
cfg.OD,
gofakeit.URL(),
cfg.BestRank,
cfg.BestRankAt,
cfg.MostPoints,
cfg.MostPointsAt,
cfg.MostVillages,
cfg.MostVillagesAt,
cfg.LastActivityAt,
now,
time.Time{},
)
require.NoError(tb, err)
return p
}

View File

@ -1,6 +1,487 @@
package domain
import (
"cmp"
"fmt"
"math"
"net/url"
"slices"
"time"
)
const (
playerNameMinLength = 1
playerNameMaxLength = 150
)
type Player struct {
id int
serverKey string
name string
numVillages int
points int
rank int
tribeID int
od OpponentsDefeated
profileURL *url.URL
bestRank int
bestRankAt time.Time
mostPoints int
mostPointsAt time.Time
mostVillages int
mostVillagesAt time.Time
lastActivityAt time.Time
createdAt time.Time
deletedAt time.Time
}
// UnmarshalPlayerFromDatabase unmarshals Player from the database.
//
// It should be used only for unmarshalling from the database!
// You can't use UnmarshalPlayerFromDatabase as constructor - It may put domain into the invalid state!
func UnmarshalPlayerFromDatabase(
id int,
serverKey string,
name string,
numVillages int,
points int,
rank int,
tribeID int,
od OpponentsDefeated,
rawProfileURL string,
bestRank int,
bestRankAt time.Time,
mostPoints int,
mostPointsAt time.Time,
mostVillages int,
mostVillagesAt time.Time,
lastActivityAt time.Time,
createdAt time.Time,
deletedAt time.Time,
) (Player, error) {
if err := validateIntInRange(id, 1, math.MaxInt); err != nil {
return Player{}, ValidationError{
Model: playerModelName,
Field: "id",
Err: err,
}
}
profileURL, err := parseURL(rawProfileURL)
if err != nil {
return Player{}, ValidationError{
Model: playerModelName,
Field: "profileURL",
Err: err,
}
}
return Player{
id: id,
serverKey: serverKey,
name: name,
numVillages: numVillages,
points: points,
rank: rank,
tribeID: tribeID,
od: od,
profileURL: profileURL,
bestRank: bestRank,
bestRankAt: bestRankAt,
mostPoints: mostPoints,
mostPointsAt: mostPointsAt,
mostVillages: mostVillages,
mostVillagesAt: mostVillagesAt,
lastActivityAt: lastActivityAt,
createdAt: createdAt,
deletedAt: deletedAt,
}, nil
}
const playerModelName = "Player"
func (p Player) ID() int {
return p.id
}
func (p Player) ServerKey() string {
return p.serverKey
}
func (p Player) Name() string {
return p.name
}
func (p Player) NumVillages() int {
return p.numVillages
}
func (p Player) Points() int {
return p.points
}
func (p Player) Rank() int {
return p.rank
}
func (p Player) TribeID() int {
return p.tribeID
}
func (p Player) OD() OpponentsDefeated {
return p.od
}
func (p Player) ProfileURL() *url.URL {
return p.profileURL
}
func (p Player) BestRank() int {
return p.bestRank
}
func (p Player) BestRankAt() time.Time {
return p.bestRankAt
}
func (p Player) MostPoints() int {
return p.mostPoints
}
func (p Player) MostPointsAt() time.Time {
return p.mostPointsAt
}
func (p Player) MostVillages() int {
return p.mostVillages
}
func (p Player) MostVillagesAt() time.Time {
return p.mostVillagesAt
}
func (p Player) LastActivityAt() time.Time {
return p.lastActivityAt
}
func (p Player) CreatedAt() time.Time {
return p.createdAt
}
func (p Player) DeletedAt() time.Time {
return p.deletedAt
}
func (p Player) Base() BasePlayer {
return BasePlayer{
id: p.id,
name: p.name,
numVillages: p.numVillages,
points: p.points,
rank: p.rank,
tribeID: p.tribeID,
od: p.od,
profileURL: p.profileURL,
}
}
func (p Player) IsDeleted() bool {
return !p.deletedAt.IsZero()
}
type Players []Player
// Delete finds all tribes that are not in the given slice with active tribes and returns their ids.
// Both slices must be sorted in ascending order by ID.
func (ps Players) Delete(active BasePlayers) []int {
//nolint:prealloc
var toDelete []int
for _, p := range ps {
_, found := slices.BinarySearchFunc(active, p, func(a BasePlayer, b Player) int {
return cmp.Compare(a.ID(), b.ID())
})
if found {
continue
}
toDelete = append(toDelete, p.ID())
}
return toDelete
}
type CreatePlayerParams struct {
base BasePlayer
serverKey string
bestRank int
bestRankAt time.Time
mostPoints int
mostPointsAt time.Time
mostVillages int
mostVillagesAt time.Time
lastActivityAt time.Time
}
const createPlayerParamsModelName = "CreatePlayerParams"
// NewCreatePlayerParams constructs a slice of CreatePlayerParams based on the given parameters.
// Both slices must be sorted in ascending order by ID
// + if storedPlayers contains tribes from different servers. they must be sorted in ascending order by server key.
//
//nolint:gocyclo
func NewCreatePlayerParams(serverKey string, players BasePlayers, storedPlayers Players) ([]CreatePlayerParams, error) {
if err := validateServerKey(serverKey); err != nil {
return nil, ValidationError{
Model: createPlayerParamsModelName,
Field: "serverKey",
Err: err,
}
}
params := make([]CreatePlayerParams, 0, len(players))
for i, player := range players {
if player.IsZero() {
return nil, fmt.Errorf("players[%d] is an empty struct", i)
}
var old Player
idx, found := slices.BinarySearchFunc(storedPlayers, player, func(a Player, b BasePlayer) int {
if res := cmp.Compare(a.ServerKey(), serverKey); res != 0 {
return res
}
return cmp.Compare(a.ID(), b.ID())
})
if found {
old = storedPlayers[idx]
}
now := time.Now()
p := CreatePlayerParams{
base: player,
serverKey: serverKey,
bestRank: player.Rank(),
bestRankAt: now,
mostPoints: player.Points(),
mostPointsAt: now,
mostVillages: player.NumVillages(),
mostVillagesAt: now,
lastActivityAt: now,
}
if old.ID() > 0 {
if old.MostPoints() >= p.MostPoints() {
p.mostPoints = old.MostPoints()
p.mostPointsAt = old.MostPointsAt()
}
if old.MostVillages() >= p.MostVillages() {
p.mostVillages = old.MostVillages()
p.mostVillagesAt = old.MostVillagesAt()
}
if old.BestRank() <= p.BestRank() {
p.bestRank = old.BestRank()
p.bestRankAt = old.BestRankAt()
}
//nolint:lll
if player.Points() <= old.Points() && player.NumVillages() <= old.NumVillages() && player.OD().ScoreAtt() <= old.OD().ScoreAtt() {
p.lastActivityAt = old.LastActivityAt()
}
}
params = append(params, p)
}
return params, nil
}
func (c CreatePlayerParams) Base() BasePlayer {
return c.base
}
func (c CreatePlayerParams) ServerKey() string {
return c.serverKey
}
func (c CreatePlayerParams) BestRank() int {
return c.bestRank
}
func (c CreatePlayerParams) BestRankAt() time.Time {
return c.bestRankAt
}
func (c CreatePlayerParams) MostPoints() int {
return c.mostPoints
}
func (c CreatePlayerParams) MostPointsAt() time.Time {
return c.mostPointsAt
}
func (c CreatePlayerParams) MostVillages() int {
return c.mostVillages
}
func (c CreatePlayerParams) MostVillagesAt() time.Time {
return c.mostVillagesAt
}
func (c CreatePlayerParams) LastActivityAt() time.Time {
return c.lastActivityAt
}
type PlayerSort uint8
const (
PlayerSortIDASC PlayerSort = iota + 1
PlayerSortIDDESC
PlayerSortServerKeyASC
PlayerSortServerKeyDESC
)
const PlayerListMaxLimit = 200
type ListPlayersParams struct {
ids []int
idGT NullInt
serverKeys []string
deleted NullBool
sort []PlayerSort
limit int
offset int
}
const listPlayersParamsModelName = "ListPlayersParams"
func NewListPlayersParams() ListPlayersParams {
return ListPlayersParams{
sort: []PlayerSort{
PlayerSortServerKeyASC,
PlayerSortIDASC,
},
limit: PlayerListMaxLimit,
}
}
func (params *ListPlayersParams) IDs() []int {
return params.ids
}
func (params *ListPlayersParams) SetIDs(ids []int) error {
for i, id := range ids {
if err := validateIntInRange(id, 0, math.MaxInt); err != nil {
return SliceElementValidationError{
Model: listPlayersParamsModelName,
Field: "ids",
Index: i,
Err: err,
}
}
}
params.ids = ids
return nil
}
func (params *ListPlayersParams) IDGT() NullInt {
return params.idGT
}
func (params *ListPlayersParams) SetIDGT(idGT NullInt) error {
if idGT.Valid {
if err := validateIntInRange(idGT.Value, 0, math.MaxInt); err != nil {
return ValidationError{
Model: listPlayersParamsModelName,
Field: "idGT",
Err: err,
}
}
}
params.idGT = idGT
return nil
}
func (params *ListPlayersParams) ServerKeys() []string {
return params.serverKeys
}
func (params *ListPlayersParams) SetServerKeys(serverKeys []string) error {
params.serverKeys = serverKeys
return nil
}
func (params *ListPlayersParams) Deleted() NullBool {
return params.deleted
}
func (params *ListPlayersParams) SetDeleted(deleted NullBool) error {
params.deleted = deleted
return nil
}
func (params *ListPlayersParams) Sort() []PlayerSort {
return params.sort
}
const (
playerSortMinLength = 1
playerSortMaxLength = 2
)
func (params *ListPlayersParams) SetSort(sort []PlayerSort) error {
if err := validateSliceLen(sort, playerSortMinLength, playerSortMaxLength); err != nil {
return ValidationError{
Model: listPlayersParamsModelName,
Field: "sort",
Err: err,
}
}
params.sort = sort
return nil
}
func (params *ListPlayersParams) Limit() int {
return params.limit
}
func (params *ListPlayersParams) SetLimit(limit int) error {
if err := validateIntInRange(limit, 1, PlayerListMaxLimit); err != nil {
return ValidationError{
Model: listPlayersParamsModelName,
Field: "limit",
Err: err,
}
}
params.limit = limit
return nil
}
func (params *ListPlayersParams) Offset() int {
return params.offset
}
func (params *ListPlayersParams) SetOffset(offset int) error {
if err := validateIntInRange(offset, 0, math.MaxInt); err != nil {
return ValidationError{
Model: listPlayersParamsModelName,
Field: "offset",
Err: err,
}
}
params.offset = offset
return nil
}

View File

@ -0,0 +1,489 @@
package domain_test
import (
"cmp"
"fmt"
"math"
"slices"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPlayers_Delete(t *testing.T) {
t.Parallel()
server := domaintest.NewServer(t)
active := domain.BasePlayers{
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
}
slices.SortFunc(active, func(a, b domain.BasePlayer) int {
return cmp.Compare(a.ID(), b.ID())
})
players := domain.Players{
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = active[0].ID()
cfg.ServerKey = server.Key()
}),
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ServerKey = server.Key()
}),
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = active[1].ID()
cfg.ServerKey = server.Key()
}),
}
expectedIDs := []int{players[1].ID()}
slices.SortFunc(players, func(a, b domain.Player) int {
if res := cmp.Compare(a.ServerKey(), b.ServerKey()); res != 0 {
return res
}
return cmp.Compare(a.ID(), b.ID())
})
assert.Equal(t, expectedIDs, players.Delete(active))
}
func TestNewCreatePlayerParams(t *testing.T) {
t.Parallel()
now := time.Now()
server := domaintest.NewServer(t)
players := domain.BasePlayers{
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
}
slices.SortFunc(players, func(a, b domain.BasePlayer) int {
return cmp.Compare(a.ID(), b.ID())
})
storedPlayers := domain.Players{
// server with random server key to verify that slice order matters
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = players[0].ID()
cfg.ServerKey = domaintest.RandServerKey()
}),
// should update BestRank/MostPoints/MostVillages/LastActivityAt
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = players[0].ID()
cfg.Points = players[0].Points() - 1
cfg.NumVillages = players[0].NumVillages() - 1
cfg.OD = players[0].OD()
cfg.ServerKey = server.Key()
cfg.BestRank = players[0].Rank() + 1
cfg.BestRankAt = now.Add(-time.Hour)
cfg.MostPoints = players[0].Points() - 1
cfg.MostPointsAt = now.Add(-time.Hour)
cfg.MostVillages = players[0].NumVillages() - 1
cfg.MostVillagesAt = now.Add(-time.Hour)
cfg.LastActivityAt = now.Add(-time.Hour)
}),
// shouldn't update BestRank/MostPoints/MostVillages/LastActivityAt
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = players[1].ID()
cfg.Points = players[1].Points() + 1
cfg.NumVillages = players[1].NumVillages() + 1
cfg.OD = players[1].OD()
cfg.ServerKey = server.Key()
cfg.BestRank = players[1].Rank() - 1
cfg.BestRankAt = now.Add(-time.Hour)
cfg.MostPoints = players[1].Points() + 1
cfg.MostPointsAt = now.Add(-time.Hour)
cfg.MostVillages = players[1].NumVillages() + 1
cfg.MostVillagesAt = now.Add(-time.Hour)
cfg.LastActivityAt = now.Add(-time.Hour)
}),
}
storedPlayersSorted := slices.Clone(storedPlayers)
slices.SortFunc(storedPlayersSorted, func(a, b domain.Player) int {
if res := cmp.Compare(a.ServerKey(), b.ServerKey()); res != 0 {
return res
}
return cmp.Compare(a.ID(), b.ID())
})
expectedParams := []struct {
base domain.BasePlayer
serverKey string
bestRank int
bestRankAt time.Time
mostPoints int
mostPointsAt time.Time
mostVillages int
mostVillagesAt time.Time
lastActivityAt time.Time
}{
{
base: players[0],
bestRank: players[0].Rank(),
bestRankAt: now,
mostPoints: players[0].Points(),
mostPointsAt: now,
mostVillages: players[0].NumVillages(),
mostVillagesAt: now,
lastActivityAt: now,
},
{
base: players[1],
bestRank: storedPlayers[2].BestRank(),
bestRankAt: storedPlayers[2].BestRankAt(),
mostPoints: storedPlayers[2].MostPoints(),
mostPointsAt: storedPlayers[2].MostPointsAt(),
mostVillages: storedPlayers[2].MostVillages(),
mostVillagesAt: storedPlayers[2].MostVillagesAt(),
lastActivityAt: storedPlayers[2].LastActivityAt(),
},
{
base: players[2],
bestRank: players[2].Rank(),
bestRankAt: now,
mostPoints: players[2].Points(),
mostPointsAt: now,
mostVillages: players[2].NumVillages(),
mostVillagesAt: now,
lastActivityAt: now,
},
}
res, err := domain.NewCreatePlayerParams(server.Key(), players, storedPlayersSorted)
require.NoError(t, err)
assert.Len(t, res, len(expectedParams))
for i, expected := range expectedParams {
idx := slices.IndexFunc(res, func(params domain.CreatePlayerParams) bool {
return params.Base().ID() == expected.base.ID() && params.ServerKey() == server.Key()
})
require.GreaterOrEqualf(t, idx, 0, "expectedParams[%d] not found", i)
params := res[idx]
assert.Equalf(t, expected.base, params.Base(), "expectedParams[%d]", i)
assert.Equalf(t, expected.bestRank, params.BestRank(), "expectedParams[%d]", i)
assert.WithinDurationf(t, expected.bestRankAt, params.BestRankAt(), time.Minute, "expectedParams[%d]", i)
assert.Equalf(t, expected.mostPoints, params.MostPoints(), "expectedParams[%d]", i)
assert.WithinDurationf(t, expected.mostPointsAt, params.MostPointsAt(), time.Minute, "expectedParams[%d]", i)
assert.Equalf(t, expected.mostVillages, params.MostVillages(), "expectedParams[%d]", i)
assert.WithinDurationf(t, expected.mostVillagesAt, params.MostVillagesAt(), time.Minute, "expectedParams[%d]", i)
assert.WithinDurationf(t, expected.lastActivityAt, params.LastActivityAt(), time.Minute, "expectedParams[%d]", i)
}
}
func TestListPlayersParams_SetIDs(t *testing.T) {
t.Parallel()
type args struct {
ids []int
}
tests := []struct {
name string
args args
expectedErr error
}{
{
name: "OK",
args: args{
ids: []int{
gofakeit.IntRange(0, math.MaxInt),
gofakeit.IntRange(0, math.MaxInt),
gofakeit.IntRange(0, math.MaxInt),
},
},
},
{
name: "ERR: value < 0",
args: args{
ids: []int{
gofakeit.IntRange(0, math.MaxInt),
gofakeit.IntRange(0, math.MaxInt),
gofakeit.IntRange(0, math.MaxInt),
-1,
gofakeit.IntRange(0, math.MaxInt),
},
},
expectedErr: domain.SliceElementValidationError{
Model: "ListPlayersParams",
Field: "ids",
Index: 3,
Err: domain.MinGreaterEqualError{
Min: 0,
Current: -1,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := domain.NewListPlayersParams()
require.ErrorIs(t, params.SetIDs(tt.args.ids), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.ids, params.IDs())
})
}
}
func TestListPlayersParams_SetIDGT(t *testing.T) {
t.Parallel()
type args struct {
idGT domain.NullInt
}
tests := []struct {
name string
args args
expectedErr error
}{
{
name: "OK",
args: args{
idGT: domain.NullInt{
Value: gofakeit.IntRange(0, math.MaxInt),
Valid: true,
},
},
},
{
name: "ERR: value < 0",
args: args{
idGT: domain.NullInt{
Value: -1,
Valid: true,
},
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "idGT",
Err: domain.MinGreaterEqualError{
Min: 0,
Current: -1,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := domain.NewListPlayersParams()
require.ErrorIs(t, params.SetIDGT(tt.args.idGT), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.idGT, params.IDGT())
})
}
}
func TestListPlayersParams_SetSort(t *testing.T) {
t.Parallel()
type args struct {
sort []domain.PlayerSort
}
tests := []struct {
name string
args args
expectedErr error
}{
{
name: "OK",
args: args{
sort: []domain.PlayerSort{
domain.PlayerSortIDASC,
domain.PlayerSortServerKeyASC,
},
},
},
{
name: "ERR: len(sort) < 1",
args: args{
sort: nil,
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Current: 0,
},
},
},
{
name: "ERR: len(sort) > 2",
args: args{
sort: []domain.PlayerSort{
domain.PlayerSortIDASC,
domain.PlayerSortServerKeyASC,
domain.PlayerSortServerKeyDESC,
},
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Current: 3,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := domain.NewListPlayersParams()
require.ErrorIs(t, params.SetSort(tt.args.sort), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.sort, params.Sort())
})
}
}
func TestListPlayersParams_SetLimit(t *testing.T) {
t.Parallel()
type args struct {
limit int
}
tests := []struct {
name string
args args
expectedErr error
}{
{
name: "OK",
args: args{
limit: domain.PlayerListMaxLimit,
},
},
{
name: "ERR: limit < 1",
args: args{
limit: 0,
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "limit",
Err: domain.MinGreaterEqualError{
Min: 1,
Current: 0,
},
},
},
{
name: fmt.Sprintf("ERR: limit > %d", domain.PlayerListMaxLimit),
args: args{
limit: domain.PlayerListMaxLimit + 1,
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "limit",
Err: domain.MaxLessEqualError{
Max: domain.PlayerListMaxLimit,
Current: domain.PlayerListMaxLimit + 1,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := domain.NewListPlayersParams()
require.ErrorIs(t, params.SetLimit(tt.args.limit), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.limit, params.Limit())
})
}
}
func TestListPlayersParams_SetOffset(t *testing.T) {
t.Parallel()
type args struct {
offset int
}
tests := []struct {
name string
args args
expectedErr error
}{
{
name: "OK",
args: args{
offset: 100,
},
},
{
name: "ERR: offset < 0",
args: args{
offset: -1,
},
expectedErr: domain.ValidationError{
Model: "ListPlayersParams",
Field: "offset",
Err: domain.MinGreaterEqualError{
Min: 0,
Current: -1,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := domain.NewListPlayersParams()
require.ErrorIs(t, params.SetOffset(tt.args.offset), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.offset, params.Offset())
})
}
}

View File

@ -274,30 +274,34 @@ func NewCreateTribeParams(serverKey string, tribes BaseTribes, storedTribes Trib
old = storedTribes[idx]
}
now := time.Now()
p := CreateTribeParams{
base: t,
serverKey: serverKey,
bestRank: old.bestRank,
bestRankAt: old.bestRankAt,
mostPoints: old.mostPoints,
mostPointsAt: old.mostPointsAt,
mostVillages: old.mostVillages,
mostVillagesAt: old.mostVillagesAt,
bestRank: t.Rank(),
bestRankAt: now,
mostPoints: t.AllPoints(),
mostPointsAt: now,
mostVillages: t.NumVillages(),
mostVillagesAt: now,
}
if t.AllPoints() > p.MostPoints() || old.ID() <= 0 {
p.mostPoints = t.AllPoints()
p.mostPointsAt = time.Now()
}
if old.ID() > 0 {
if old.MostPoints() >= p.MostPoints() {
p.mostPoints = old.MostPoints()
p.mostPointsAt = old.MostPointsAt()
}
if t.NumVillages() > p.MostVillages() || old.ID() <= 0 {
p.mostVillages = t.NumVillages()
p.mostVillagesAt = time.Now()
}
if old.MostVillages() >= p.MostVillages() {
p.mostVillages = old.MostVillages()
p.mostVillagesAt = old.MostVillagesAt()
}
if t.Rank() < p.BestRank() || old.ID() <= 0 {
p.bestRank = t.Rank()
p.bestRankAt = time.Now()
if old.BestRank() <= p.BestRank() {
p.bestRank = old.BestRank()
p.bestRankAt = old.BestRankAt()
}
}
params = append(params, p)

View File

@ -72,13 +72,7 @@ func TestNewCreateTribeParams(t *testing.T) {
storedTribes := domain.Tribes{
domaintest.NewTribe(t, func(cfg *domaintest.TribeConfig) {
cfg.ID = tribes[0].ID()
cfg.ServerKey = server.Key()[:len(server.Key())-1]
cfg.BestRank = tribes[0].Rank() + 1
cfg.BestRankAt = now.Add(-time.Hour)
cfg.MostPoints = tribes[0].AllPoints() - 1
cfg.MostPointsAt = now.Add(-time.Hour)
cfg.MostVillages = tribes[0].NumVillages() - 1
cfg.MostVillagesAt = now.Add(-time.Hour)
cfg.ServerKey = domaintest.RandServerKey()
}),
domaintest.NewTribe(t, func(cfg *domaintest.TribeConfig) {
cfg.ID = tribes[0].ID()
@ -101,7 +95,8 @@ func TestNewCreateTribeParams(t *testing.T) {
cfg.MostVillagesAt = now.Add(-time.Hour)
}),
}
slices.SortFunc(storedTribes, func(a, b domain.Tribe) int {
storedTribesSorted := slices.Clone(storedTribes)
slices.SortFunc(storedTribesSorted, func(a, b domain.Tribe) int {
if res := cmp.Compare(a.ServerKey(), b.ServerKey()); res != 0 {
return res
}
@ -146,12 +141,12 @@ func TestNewCreateTribeParams(t *testing.T) {
},
}
res, err := domain.NewCreateTribeParams(server.Key(), tribes, storedTribes)
res, err := domain.NewCreateTribeParams(server.Key(), tribes, storedTribesSorted)
require.NoError(t, err)
assert.Len(t, res, len(expectedParams))
for i, expected := range expectedParams {
idx := slices.IndexFunc(res, func(params domain.CreateTribeParams) bool {
return params.Base().ID() == expected.base.ID()
return params.Base().ID() == expected.base.ID() && params.ServerKey() == server.Key()
})
require.GreaterOrEqualf(t, idx, 0, "expectedParams[%d] not found", i)

View File

@ -1,18 +1,9 @@
package domain
import (
"fmt"
"net/url"
)
type InvalidURLError struct {
URL string
}
func (e InvalidURLError) Error() string {
return fmt.Sprintf("%s: invalid URL", e.URL)
}
func parseURL(rawURL string) (*url.URL, error) {
u, err := url.ParseRequestURI(rawURL)
if err != nil {

View File

@ -193,6 +193,30 @@ func (e LenOutOfRangeError) Params() map[string]any {
}
}
type InvalidURLError struct {
URL string
}
var _ ErrorWithParams = InvalidURLError{}
func (e InvalidURLError) Error() string {
return fmt.Sprintf("%s: invalid URL", e.URL)
}
func (e InvalidURLError) Code() ErrorCode {
return ErrorCodeIncorrectInput
}
func (e InvalidURLError) Slug() string {
return "invalid-url"
}
func (e InvalidURLError) Params() map[string]any {
return map[string]any{
"URL": e.URL,
}
}
var ErrRequired error = simpleError{
msg: "can't be blank",
code: ErrorCodeIncorrectInput,