feat: tribe changes (#29)

Reviewed-on: twhelp/corev3#29
This commit is contained in:
Dawid Wysokiński 2024-01-06 09:26:56 +00:00
parent 7a1e4dfb50
commit 57de175c66
31 changed files with 1525 additions and 92 deletions

View File

@ -42,11 +42,6 @@ var cmdConsumer = &cli.Command{
marshaler watermillmsg.Marshaler,
db *bun.DB,
) error {
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
serverPublisher := adapter.NewServerWatermillPublisher(
publisher,
marshaler,
@ -54,6 +49,11 @@ var cmdConsumer = &cli.Command{
c.String(rmqFlagTopicServerSyncedEvent.Name),
)
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
consumer := port.NewServerWatermillConsumer(
app.NewServerService(adapter.NewServerBunRepository(db), twSvc, serverPublisher),
subscriber,
@ -90,17 +90,17 @@ var cmdConsumer = &cli.Command{
marshaler watermillmsg.Marshaler,
db *bun.DB,
) error {
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
tribePublisher := adapter.NewTribeWatermillPublisher(
publisher,
marshaler,
c.String(rmqFlagTopicTribesSyncedEvent.Name),
)
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
consumer := port.NewTribeWatermillConsumer(
app.NewTribeService(adapter.NewTribeBunRepository(db), twSvc, tribePublisher),
subscriber,
@ -133,19 +133,26 @@ var cmdConsumer = &cli.Command{
marshaler watermillmsg.Marshaler,
db *bun.DB,
) error {
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
playerPublisher := adapter.NewPlayerWatermillPublisher(
publisher,
marshaler,
c.String(rmqFlagTopicPlayersSyncedEvent.Name),
)
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
tribeChangeSvc := app.NewTribeChangeService(adapter.NewTribeChangeBunRepository(db))
consumer := port.NewPlayerWatermillConsumer(
app.NewPlayerService(adapter.NewPlayerBunRepository(db), twSvc, playerPublisher),
app.NewPlayerService(
adapter.NewPlayerBunRepository(db),
tribeChangeSvc,
twSvc,
playerPublisher,
),
subscriber,
logger,
marshaler,
@ -175,17 +182,17 @@ var cmdConsumer = &cli.Command{
marshaler watermillmsg.Marshaler,
db *bun.DB,
) error {
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
villagePublisher := adapter.NewVillageWatermillPublisher(
publisher,
marshaler,
c.String(rmqFlagTopicVillagesSyncedEvent.Name),
)
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
consumer := port.NewVillageWatermillConsumer(
app.NewVillageService(adapter.NewVillageBunRepository(db), twSvc, villagePublisher),
subscriber,
@ -217,11 +224,6 @@ var cmdConsumer = &cli.Command{
marshaler watermillmsg.Marshaler,
db *bun.DB,
) error {
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
ennoblementPublisher := adapter.NewEnnoblementWatermillPublisher(
publisher,
marshaler,
@ -229,6 +231,11 @@ var cmdConsumer = &cli.Command{
c.String(rmqFlagTopicEnnoblementsSyncedEvent.Name),
)
twSvc, err := newTWServiceFromFlags(c)
if err != nil {
return err
}
consumer := port.NewEnnoblementWatermillConsumer(
app.NewEnnoblementService(adapter.NewEnnoblementBunRepository(db), twSvc, ennoblementPublisher),
subscriber,

View File

@ -23,6 +23,7 @@ func NewFixture(bunDB *bun.DB) *Fixture {
(*bunmodel.Player)(nil),
(*bunmodel.Village)(nil),
(*bunmodel.Ennoblement)(nil),
(*bunmodel.TribeChange)(nil),
)
return &Fixture{
f: dbfixture.New(bunDB),

View File

@ -19,6 +19,9 @@ func NewBunDBSQLite(tb TestingTB) *bun.DB {
sqlDB.SetConnMaxLifetime(0)
bunDB := bun.NewDB(sqlDB, sqlitedialect.New())
tb.Cleanup(func() {
_ = bunDB.Close()
})
bunDB.AddQueryHook(newBunDebugHook())

View File

@ -0,0 +1,60 @@
package bunmodel
import (
"fmt"
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"github.com/uptrace/bun"
)
type TribeChange struct {
bun.BaseModel `bun:"table:tribe_changes,alias:tc"`
ID int `bun:"id,pk,autoincrement,identity"`
ServerKey string `bun:"server_key,nullzero"`
PlayerID int `bun:"player_id,nullzero"`
Player Player `bun:"player,rel:belongs-to,join:player_id=id,join:server_key=server_key"`
NewTribeID int `bun:"new_tribe_id,nullzero"`
NewTribe Tribe `bun:"new_tribe,rel:belongs-to,join:new_tribe_id=id,join:server_key=server_key"`
OldTribeID int `bun:"old_tribe_id,nullzero"`
OldTribe Tribe `bun:"old_tribe,rel:belongs-to,join:old_tribe_id=id,join:server_key=server_key"`
CreatedAt time.Time `bun:"created_at,nullzero"`
}
func (tc TribeChange) ToDomain() (domain.TribeChange, error) {
converted, err := domain.UnmarshalTribeChangeFromDatabase(
tc.ID,
tc.ServerKey,
tc.PlayerID,
tc.OldTribeID,
tc.NewTribeID,
tc.CreatedAt,
)
if err != nil {
return domain.TribeChange{}, fmt.Errorf(
"couldn't construct domain.TribeChange (id=%d): %w",
tc.ID,
err,
)
}
return converted, nil
}
type TribeChanges []TribeChange
func (tcs TribeChanges) ToDomain() (domain.TribeChanges, error) {
res := make(domain.TribeChanges, 0, len(tcs))
for _, tc := range tcs {
converted, err := tc.ToDomain()
if err != nil {
return nil, err
}
res = append(res, converted)
}
return res, nil
}

View File

@ -26,6 +26,7 @@ func (repo *PlayerBunRepository) CreateOrUpdate(ctx context.Context, params ...d
return nil
}
now := time.Now()
players := make(bunmodel.Players, 0, len(params))
for _, p := range params {
@ -46,7 +47,7 @@ func (repo *PlayerBunRepository) CreateOrUpdate(ctx context.Context, params ...d
MostVillages: p.MostVillages(),
MostVillagesAt: p.MostVillagesAt(),
LastActivityAt: p.LastActivityAt(),
CreatedAt: time.Now(),
CreatedAt: now,
OpponentsDefeated: bunmodel.NewOpponentsDefeated(base.OD()),
})
}

View File

@ -26,6 +26,7 @@ func (repo *ServerBunRepository) CreateOrUpdate(ctx context.Context, params ...d
return nil
}
now := time.Now()
servers := make(bunmodel.Servers, 0, len(params))
for _, p := range params {
@ -35,7 +36,7 @@ func (repo *ServerBunRepository) CreateOrUpdate(ctx context.Context, params ...d
URL: base.URL().String(),
Open: base.Open(),
VersionCode: p.VersionCode(),
CreatedAt: time.Now(),
CreatedAt: now,
})
}

View File

@ -26,6 +26,7 @@ func (repo *TribeBunRepository) CreateOrUpdate(ctx context.Context, params ...do
return nil
}
now := time.Now()
tribes := make(bunmodel.Tribes, 0, len(params))
for _, p := range params {
@ -47,7 +48,7 @@ func (repo *TribeBunRepository) CreateOrUpdate(ctx context.Context, params ...do
MostPointsAt: p.MostPointsAt(),
MostVillages: p.MostVillages(),
MostVillagesAt: p.MostVillagesAt(),
CreatedAt: time.Now(),
CreatedAt: now,
OpponentsDefeated: bunmodel.NewOpponentsDefeated(base.OD()),
})
}

View File

@ -0,0 +1,94 @@
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"
)
type TribeChangeBunRepository struct {
db bun.IDB
}
func NewTribeChangeBunRepository(db bun.IDB) *TribeChangeBunRepository {
return &TribeChangeBunRepository{db: db}
}
func (repo *TribeChangeBunRepository) Create(ctx context.Context, params ...domain.CreateTribeChangeParams) error {
if len(params) == 0 {
return nil
}
now := time.Now()
tribeChanges := make(bunmodel.TribeChanges, 0, len(params))
for _, p := range params {
tribeChanges = append(tribeChanges, bunmodel.TribeChange{
ServerKey: p.ServerKey(),
PlayerID: p.PlayerID(),
NewTribeID: p.NewTribeID(),
OldTribeID: p.OldTribeID(),
CreatedAt: now,
})
}
if _, err := repo.db.NewInsert().
Model(&tribeChanges).
Ignore().
Returning("").
Exec(ctx); err != nil {
return fmt.Errorf("something went wrong while inserting tribe changes into the db: %w", err)
}
return nil
}
func (repo *TribeChangeBunRepository) List(
ctx context.Context,
params domain.ListTribeChangesParams,
) (domain.TribeChanges, error) {
var tribeChanges bunmodel.TribeChanges
if err := repo.db.NewSelect().
Model(&tribeChanges).
Apply(listTribeChangesParamsApplier{params: params}.apply).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("couldn't select tribe changes from the db: %w", err)
}
return tribeChanges.ToDomain()
}
type listTribeChangesParamsApplier struct {
params domain.ListTribeChangesParams
}
//nolint:gocyclo
func (a listTribeChangesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 {
q = q.Where("tc.server_key IN (?)", bun.In(serverKeys))
}
for _, s := range a.params.Sort() {
switch s {
case domain.TribeChangeSortCreatedAtASC:
q = q.Order("tc.created_at ASC")
case domain.TribeChangeSortCreatedAtDESC:
q = q.Order("tc.created_at DESC")
case domain.TribeChangeSortServerKeyASC:
q = q.Order("tc.server_key ASC")
case domain.TribeChangeSortServerKeyDESC:
q = q.Order("tc.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 TestTribeChangeBunRepository_Postgres(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
testTribeChangeRepository(t, func(t *testing.T) repositories {
t.Helper()
return newBunDBRepositories(t, postgres.NewBunDB(t))
})
}
func TestTribeChangeBunRepository_SQLite(t *testing.T) {
t.Parallel()
testTribeChangeRepository(t, func(t *testing.T) repositories {
t.Helper()
return newBunDBRepositories(t, adaptertest.NewBunDBSQLite(t))
})
}

View File

@ -26,6 +26,7 @@ func (repo *VillageBunRepository) CreateOrUpdate(ctx context.Context, params ...
return nil
}
now := time.Now()
villages := make(bunmodel.Villages, 0, len(params))
for _, p := range params {
@ -41,7 +42,7 @@ func (repo *VillageBunRepository) CreateOrUpdate(ctx context.Context, params ...
Bonus: base.Bonus(),
PlayerID: base.PlayerID(),
ProfileURL: base.ProfileURL().String(),
CreatedAt: time.Now(),
CreatedAt: now,
})
}

View File

@ -201,8 +201,8 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit
serverKeys := params.ServerKeys()
for _, v := range ennoblements {
assert.True(t, slices.Contains(serverKeys, v.ServerKey()))
for _, e := range ennoblements {
assert.True(t, slices.Contains(serverKeys, e.ServerKey()))
}
},
assertError: func(t *testing.T, err error) {

View File

@ -49,6 +49,11 @@ type ennoblementRepository interface {
List(ctx context.Context, params domain.ListEnnoblementsParams) (domain.Ennoblements, error)
}
type tribeChangeRepository interface {
Create(ctx context.Context, params ...domain.CreateTribeChangeParams) error
List(ctx context.Context, params domain.ListTribeChangesParams) (domain.TribeChanges, error)
}
type repositories struct {
version versionRepository
server serverRepository
@ -56,6 +61,7 @@ type repositories struct {
player playerRepository
village villageRepository
ennoblement ennoblementRepository
tribeChange tribeChangeRepository
}
func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories {
@ -70,5 +76,6 @@ func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories {
player: adapter.NewPlayerBunRepository(bunDB),
village: adapter.NewVillageBunRepository(bunDB),
ennoblement: adapter.NewEnnoblementBunRepository(bunDB),
tribeChange: adapter.NewTribeChangeBunRepository(bunDB),
}
}

View File

@ -0,0 +1,272 @@
package adapter_test
import (
"cmp"
"context"
"fmt"
"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 testTribeChangeRepository(t *testing.T, newRepos func(t *testing.T) repositories) {
t.Helper()
ctx := context.Background()
t.Run("Create", func(t *testing.T) {
t.Parallel()
repos := newRepos(t)
assertCreated := func(t *testing.T, params []domain.CreateTribeChangeParams) {
t.Helper()
require.NotEmpty(t, params)
listParams := domain.NewListTribeChangesParams()
require.NoError(t, listParams.SetServerKeys([]string{params[0].ServerKey()}))
tribeChanges, err := repos.tribeChange.List(ctx, listParams)
require.NoError(t, err)
for i, p := range params {
idx := slices.IndexFunc(tribeChanges, func(tc domain.TribeChange) bool {
return tc.ServerKey() == p.ServerKey() &&
tc.PlayerID() == p.PlayerID() &&
tc.OldTribeID() == p.OldTribeID() &&
tc.NewTribeID() == p.NewTribeID()
})
require.GreaterOrEqualf(t, idx, 0, "params[%d] not found", i)
tribeChange := tribeChanges[idx]
assert.Equalf(t, p.ServerKey(), tribeChange.ServerKey(), "params[%d]", i)
assert.Equalf(t, p.PlayerID(), tribeChange.PlayerID(), "params[%d]", i)
assert.Equalf(t, p.OldTribeID(), tribeChange.OldTribeID(), "params[%d]", i)
assert.Equalf(t, p.NewTribeID(), tribeChange.NewTribeID(), "params[%d]", i)
assert.WithinDurationf(t, time.Now(), tribeChange.CreatedAt(), time.Minute, "params[%d]", i)
}
}
assertNoDuplicates := func(t *testing.T, params []domain.CreateTribeChangeParams) {
t.Helper()
require.NotEmpty(t, params)
listParams := domain.NewListTribeChangesParams()
require.NoError(t, listParams.SetServerKeys([]string{params[0].ServerKey()}))
tribeChanges, err := repos.tribeChange.List(ctx, listParams)
require.NoError(t, err)
res := make(map[string][]int)
for _, p := range params {
key := fmt.Sprintf("%s-%d-%d-%d", p.ServerKey(), p.PlayerID(), p.OldTribeID(), p.NewTribeID())
for i, tc := range tribeChanges {
//nolint:lll
if tc.ServerKey() == p.ServerKey() && tc.PlayerID() == p.PlayerID() && tc.OldTribeID() == p.OldTribeID() && tc.NewTribeID() == p.NewTribeID() {
res[key] = append(res[key], i)
}
}
}
for key, indexes := range res {
assert.Len(t, indexes, 1, key)
}
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
players, err := repos.player.List(ctx, domain.NewListPlayersParams())
require.NoError(t, err)
require.NotEmpty(t, players)
player := players[0]
p1, err := domain.NewCreateTribeChangeParams(
player.ServerKey(),
player.ID(),
0,
domaintest.RandID(),
)
require.NoError(t, err)
p2, err := domain.NewCreateTribeChangeParams(
player.ServerKey(),
player.ID(),
p1.OldTribeID(),
player.TribeID(),
)
require.NoError(t, err)
p3, err := domain.NewCreateTribeChangeParams(
player.ServerKey(),
player.ID(),
p2.NewTribeID(),
0,
)
require.NoError(t, err)
createParams := []domain.CreateTribeChangeParams{
p1,
p2,
p3,
}
require.NoError(t, repos.tribeChange.Create(ctx, createParams...))
assertCreated(t, createParams)
require.NoError(t, repos.tribeChange.Create(ctx, createParams...))
assertNoDuplicates(t, createParams)
})
t.Run("OK: len(params) == 0", func(t *testing.T) {
t.Parallel()
require.NoError(t, repos.tribeChange.Create(ctx))
})
})
t.Run("List & ListCount", func(t *testing.T) {
t.Parallel()
repos := newRepos(t)
tribeChanges, listTribeChangesErr := repos.tribeChange.List(ctx, domain.NewListTribeChangesParams())
require.NoError(t, listTribeChangesErr)
require.NotEmpty(t, tribeChanges)
randTribeChange := tribeChanges[0]
tests := []struct {
name string
params func(t *testing.T) domain.ListTribeChangesParams
assertTribeChanges func(t *testing.T, params domain.ListTribeChangesParams, tribeChanges domain.TribeChanges)
assertError func(t *testing.T, err error)
assertTotal func(t *testing.T, params domain.ListTribeChangesParams, total int)
}{
{
name: "OK: default params",
params: func(t *testing.T) domain.ListTribeChangesParams {
t.Helper()
return domain.NewListTribeChangesParams()
},
assertTribeChanges: func(t *testing.T, params domain.ListTribeChangesParams, tribeChanges domain.TribeChanges) {
t.Helper()
assert.NotEmpty(t, len(tribeChanges))
assert.True(t, slices.IsSortedFunc(tribeChanges, func(a, b domain.TribeChange) int {
if x := cmp.Compare(a.ServerKey(), b.ServerKey()); x != 0 {
return x
}
return a.CreatedAt().Compare(b.CreatedAt())
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListTribeChangesParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: "OK: sort=[serverKey DESC, createdAt DESC]",
params: func(t *testing.T) domain.ListTribeChangesParams {
t.Helper()
params := domain.NewListTribeChangesParams()
require.NoError(t, params.SetSort([]domain.TribeChangeSort{
domain.TribeChangeSortServerKeyDESC,
domain.TribeChangeSortCreatedAtDESC,
}))
return params
},
assertTribeChanges: func(t *testing.T, params domain.ListTribeChangesParams, tribeChanges domain.TribeChanges) {
t.Helper()
assert.NotEmpty(t, len(tribeChanges))
assert.True(t, slices.IsSortedFunc(tribeChanges, func(a, b domain.TribeChange) int {
if x := cmp.Compare(a.ServerKey(), b.ServerKey()) * -1; x != 0 {
return x
}
return a.CreatedAt().Compare(b.CreatedAt()) * -1
}))
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListTribeChangesParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: fmt.Sprintf("OK: serverKeys=[%s]", randTribeChange.ServerKey()),
params: func(t *testing.T) domain.ListTribeChangesParams {
t.Helper()
params := domain.NewListTribeChangesParams()
require.NoError(t, params.SetServerKeys([]string{randTribeChange.ServerKey()}))
return params
},
assertTribeChanges: func(t *testing.T, params domain.ListTribeChangesParams, tribeChanges domain.TribeChanges) {
t.Helper()
serverKeys := params.ServerKeys()
for _, tc := range tribeChanges {
assert.True(t, slices.Contains(serverKeys, tc.ServerKey()))
}
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListTribeChangesParams, total int) {
t.Helper()
assert.NotEmpty(t, total)
},
},
{
name: "OK: offset=1 limit=2",
params: func(t *testing.T) domain.ListTribeChangesParams {
t.Helper()
params := domain.NewListTribeChangesParams()
require.NoError(t, params.SetOffset(1))
require.NoError(t, params.SetLimit(2))
return params
},
assertTribeChanges: func(t *testing.T, params domain.ListTribeChangesParams, tribeChanges domain.TribeChanges) {
t.Helper()
assert.Len(t, tribeChanges, params.Limit())
},
assertError: func(t *testing.T, err error) {
t.Helper()
require.NoError(t, err)
},
assertTotal: func(t *testing.T, params domain.ListTribeChangesParams, 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.tribeChange.List(ctx, params)
tt.assertError(t, err)
tt.assertTribeChanges(t, params, res)
})
}
})
}

View File

@ -7330,3 +7330,47 @@
old_tribe_id: 31
points: 5123
created_at: 2022-04-22T15:00:10.000Z
- model: TribeChange
rows:
- _id: de188-1577279214-0-772-1
id: 10000
player_id: 1577279214
old_tribe_id: 0
new_tribe_id: 772
server_key: de188
created_at: 2021-06-25T12:00:53.000Z
- _id: de188-1577279214-772-0
id: 10001
player_id: 1577279214
old_tribe_id: 772
new_tribe_id: 0
server_key: de188
created_at: 2021-06-30T12:00:53.000Z
- _id: de188-1577279214-0-772-2
id: 10002
player_id: 1577279214
old_tribe_id: 0
new_tribe_id: 772
server_key: de188
created_at: 2021-07-01T12:00:53.000Z
- _id: de188-2572835-0-772
id: 10010
player_id: 2572835
old_tribe_id: 0
new_tribe_id: 772
server_key: de188
created_at: 2021-02-25T18:01:02.000Z
- _id: pl169-6180190-0-27
id: 10100
player_id: 6180190
old_tribe_id: 0
new_tribe_id: 27
server_key: pl169
created_at: 2021-09-04T21:01:03.000Z
- _id: pl169-8419570-0-2
id: 10110
player_id: 8419570
old_tribe_id: 0
new_tribe_id: 2
server_key: pl169
created_at: 2021-09-10T20:01:11.000Z

View File

@ -18,13 +18,19 @@ type PlayerRepository interface {
}
type PlayerService struct {
repo PlayerRepository
twSvc TWService
pub PlayerPublisher
repo PlayerRepository
tribeChangeSvc *TribeChangeService
twSvc TWService
pub PlayerPublisher
}
func NewPlayerService(repo PlayerRepository, twSvc TWService, pub PlayerPublisher) *PlayerService {
return &PlayerService{repo: repo, twSvc: twSvc, pub: pub}
func NewPlayerService(
repo PlayerRepository,
tribeChangeSvc *TribeChangeService,
twSvc TWService,
pub PlayerPublisher,
) *PlayerService {
return &PlayerService{repo: repo, tribeChangeSvc: tribeChangeSvc, twSvc: twSvc, pub: pub}
}
func (svc *PlayerService) Sync(ctx context.Context, serverSyncedPayload domain.ServerSyncedEventPayload) error {
@ -108,9 +114,19 @@ func (svc *PlayerService) createOrUpdateChunk(ctx context.Context, serverKey str
return err
}
return svc.repo.CreateOrUpdate(ctx, createParams...)
tribeChangesParams, err := domain.NewCreateTribeChangeParamsFromPlayers(serverKey, players, storedPlayers)
if err != nil {
return err
}
if err = svc.repo.CreateOrUpdate(ctx, createParams...); err != nil {
return err
}
return svc.tribeChangeSvc.Create(ctx, tribeChangesParams...)
}
//nolint:gocyclo
func (svc *PlayerService) delete(ctx context.Context, serverKey string, players domain.BasePlayers) error {
listParams := domain.NewListPlayersParams()
if err := listParams.SetServerKeys([]string{serverKey}); err != nil {
@ -130,6 +146,7 @@ func (svc *PlayerService) delete(ctx context.Context, serverKey string, players
}
var toDelete []int
var tribeChangesParams []domain.CreateTribeChangeParams
for {
storedPlayers, err := svc.repo.List(ctx, listParams)
@ -141,7 +158,13 @@ func (svc *PlayerService) delete(ctx context.Context, serverKey string, players
break
}
toDelete = append(toDelete, storedPlayers.Delete(players)...)
ids, params, err := storedPlayers.Delete(serverKey, players)
if err != nil {
return err
}
toDelete = append(toDelete, ids...)
tribeChangesParams = append(tribeChangesParams, params...)
if err = listParams.SetIDGT(domain.NullInt{
Value: storedPlayers[len(storedPlayers)-1].ID(),
@ -151,5 +174,9 @@ func (svc *PlayerService) delete(ctx context.Context, serverKey string, players
}
}
return svc.repo.Delete(ctx, serverKey, toDelete...)
if err := svc.repo.Delete(ctx, serverKey, toDelete...); err != nil {
return err
}
return svc.tribeChangeSvc.Create(ctx, tribeChangesParams...)
}

View File

@ -0,0 +1,42 @@
package app
import (
"context"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
)
type TribeChangeRepository interface {
// Create persists tribe changes in a store (e.g. Postgres).
// If there is a similar tribe change, such changes are ignored.
// Similar means that there is a tribe change with the same player id,
// new tribe id and old tribe id and this tribe change was created within the same hour.
Create(ctx context.Context, params ...domain.CreateTribeChangeParams) error
}
type TribeChangeService struct {
repo TribeChangeRepository
}
func NewTribeChangeService(repo TribeChangeRepository) *TribeChangeService {
return &TribeChangeService{repo: repo}
}
const (
tribeChangeChunkSize = 500
)
func (svc *TribeChangeService) Create(ctx context.Context, params ...domain.CreateTribeChangeParams) error {
for i := 0; i < len(params); i += tribeChangeChunkSize {
end := i + tribeChangeChunkSize
if end > len(params) {
end = len(params)
}
if err := svc.repo.Create(ctx, params[i:end]...); err != nil {
return err
}
}
return nil
}

View File

@ -13,6 +13,7 @@ type PlayerConfig struct {
Points int
NumVillages int
ServerKey string
TribeID int
OD domain.OpponentsDefeated
BestRank int
BestRankAt time.Time
@ -21,6 +22,7 @@ type PlayerConfig struct {
MostVillages int
MostVillagesAt time.Time
LastActivityAt time.Time
DeletedAt time.Time
}
func NewPlayer(tb TestingTB, opts ...func(cfg *PlayerConfig)) domain.Player {
@ -33,6 +35,7 @@ func NewPlayer(tb TestingTB, opts ...func(cfg *PlayerConfig)) domain.Player {
ServerKey: RandServerKey(),
Points: gofakeit.IntRange(1, 10000),
NumVillages: gofakeit.IntRange(1, 10000),
TribeID: RandID(),
OD: NewOpponentsDefeated(tb),
BestRank: gofakeit.IntRange(1, 10000),
BestRankAt: now,
@ -41,6 +44,7 @@ func NewPlayer(tb TestingTB, opts ...func(cfg *PlayerConfig)) domain.Player {
MostVillages: gofakeit.IntRange(1, 10000),
MostVillagesAt: now,
LastActivityAt: now,
DeletedAt: time.Time{},
}
for _, opt := range opts {
@ -54,7 +58,7 @@ func NewPlayer(tb TestingTB, opts ...func(cfg *PlayerConfig)) domain.Player {
cfg.NumVillages,
cfg.Points,
gofakeit.IntRange(1, 10000),
gofakeit.IntRange(1, 10000),
cfg.TribeID,
cfg.OD,
gofakeit.URL(),
cfg.BestRank,
@ -65,7 +69,7 @@ func NewPlayer(tb TestingTB, opts ...func(cfg *PlayerConfig)) domain.Player {
cfg.MostVillagesAt,
cfg.LastActivityAt,
now,
time.Time{},
cfg.DeletedAt,
)
require.NoError(tb, err)

View File

@ -28,17 +28,19 @@ type TribeConfig struct {
func NewTribe(tb TestingTB, opts ...func(cfg *TribeConfig)) domain.Tribe {
tb.Helper()
now := time.Now()
cfg := &TribeConfig{
ID: RandID(),
ServerKey: RandServerKey(),
Tag: RandTribeTag(),
OD: NewOpponentsDefeated(tb),
BestRank: gofakeit.IntRange(1, 10000),
BestRankAt: time.Now(),
BestRankAt: now,
MostPoints: gofakeit.IntRange(1, 10000),
MostPointsAt: time.Now(),
MostPointsAt: now,
MostVillages: gofakeit.IntRange(1, 10000),
MostVillagesAt: time.Now(),
MostVillagesAt: now,
}
for _, opt := range opts {
@ -64,7 +66,7 @@ func NewTribe(tb TestingTB, opts ...func(cfg *TribeConfig)) domain.Tribe {
cfg.MostPointsAt,
cfg.MostVillages,
cfg.MostVillagesAt,
time.Now(),
now,
time.Time{},
)
require.NoError(tb, err)

View File

@ -0,0 +1,46 @@
package domaintest
import (
"time"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"github.com/stretchr/testify/require"
)
type TribeChangeConfig struct {
ID int
ServerKey string
PlayerID int
OldTribeID int
NewTribeID int
CreatedAt time.Time
}
func NewTribeChange(tb TestingTB, opts ...func(cfg *TribeChangeConfig)) domain.TribeChange {
tb.Helper()
cfg := &TribeChangeConfig{
ID: RandID(),
ServerKey: RandServerKey(),
PlayerID: RandID(),
OldTribeID: RandID(),
NewTribeID: RandID(),
CreatedAt: time.Now(),
}
for _, opt := range opts {
opt(cfg)
}
tc, err := domain.UnmarshalTribeChangeFromDatabase(
cfg.ID,
cfg.ServerKey,
cfg.PlayerID,
cfg.OldTribeID,
cfg.NewTribeID,
cfg.CreatedAt,
)
require.NoError(tb, err)
return tc
}

View File

@ -199,13 +199,21 @@ func (p Player) IsDeleted() bool {
type Players []Player
// Delete finds all players that are not in the given slice with active players and returns their ids.
// Delete finds all players with the given serverKey that are not in the given slice with active players
// and returns their ids + tribe changes that must be created.
// Both slices must be sorted in ascending order by ID.
func (ps Players) Delete(active BasePlayers) []int {
// + if ps contains players from different servers. they must be sorted in ascending order by server key.
func (ps Players) Delete(serverKey string, active BasePlayers) ([]int, []CreateTribeChangeParams, error) {
// players are deleted now and then, there is no point in prereallocating these slices
//nolint:prealloc
var toDelete []int
var params []CreateTribeChangeParams
for _, p := range ps {
if p.IsDeleted() || p.ServerKey() != serverKey {
continue
}
_, found := slices.BinarySearchFunc(active, p, func(a BasePlayer, b Player) int {
return cmp.Compare(a.ID(), b.ID())
})
@ -214,9 +222,18 @@ func (ps Players) Delete(active BasePlayers) []int {
}
toDelete = append(toDelete, p.ID())
if p.TribeID() > 0 {
p, err := NewCreateTribeChangeParams(serverKey, p.ID(), p.TribeID(), 0)
if err != nil {
return nil, nil, err
}
params = append(params, p)
}
}
return toDelete
return toDelete, params, nil
}
type CreatePlayerParams struct {

View File

@ -23,24 +23,54 @@ func TestPlayers_Delete(t *testing.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) { // should be deleted
cfg.ServerKey = server.Key()
cfg.TribeID = 0
}),
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) { // should be deleted + tribe change
cfg.ServerKey = server.Key()
}),
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ServerKey = server.Key()
cfg.DeletedAt = time.Now()
}),
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = active[1].ID()
cfg.ServerKey = server.Key()
}),
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = active[0].ID()
cfg.ServerKey = domaintest.RandServerKey()
}),
}
expectedIDs := []int{players[1].ID()}
expectedIDs := []int{players[1].ID(), players[2].ID()}
expectedCreateTribeChangeParams := []struct {
serverKey string
playerID int
oldTribeID int
newTribeID int
}{
{
serverKey: server.Key(),
playerID: players[2].ID(),
oldTribeID: players[2].TribeID(),
newTribeID: 0,
},
}
slices.Sort(expectedIDs)
slices.SortFunc(active, func(a, b domain.BasePlayer) int {
return cmp.Compare(a.ID(), b.ID())
})
slices.SortFunc(players, func(a, b domain.Player) int {
if res := cmp.Compare(a.ServerKey(), b.ServerKey()); res != 0 {
return res
@ -48,7 +78,23 @@ func TestPlayers_Delete(t *testing.T) {
return cmp.Compare(a.ID(), b.ID())
})
assert.Equal(t, expectedIDs, players.Delete(active))
ids, tribeChangesParams, err := players.Delete(server.Key(), active)
require.NoError(t, err)
assert.Equal(t, expectedIDs, ids)
assert.Len(t, tribeChangesParams, len(expectedCreateTribeChangeParams))
for i, expected := range expectedCreateTribeChangeParams {
idx := slices.IndexFunc(tribeChangesParams, func(params domain.CreateTribeChangeParams) bool {
return params.PlayerID() == expected.playerID && params.ServerKey() == expected.serverKey
})
require.GreaterOrEqualf(t, idx, 0, "expectedCreateTribeChangeParams[%d] not found", i)
params := tribeChangesParams[idx]
assert.Equalf(t, expected.serverKey, params.ServerKey(), "expectedCreateTribeChangeParams[%d]", i)
assert.Equalf(t, expected.playerID, params.PlayerID(), "expectedCreateTribeChangeParams[%d]", i)
assert.Equalf(t, expected.newTribeID, params.NewTribeID(), "expectedCreateTribeChangeParams[%d]", i)
assert.Equalf(t, expected.oldTribeID, params.OldTribeID(), "expectedCreateTribeChangeParams[%d]", i)
}
}
func TestNewCreatePlayerParams(t *testing.T) {
@ -125,6 +171,7 @@ func TestNewCreatePlayerParams(t *testing.T) {
}{
{
base: players[0],
serverKey: server.Key(),
bestRank: players[0].Rank(),
bestRankAt: now,
mostPoints: players[0].Points(),
@ -135,6 +182,7 @@ func TestNewCreatePlayerParams(t *testing.T) {
},
{
base: players[1],
serverKey: server.Key(),
bestRank: storedPlayers[2].BestRank(),
bestRankAt: storedPlayers[2].BestRankAt(),
mostPoints: storedPlayers[2].MostPoints(),
@ -145,6 +193,7 @@ func TestNewCreatePlayerParams(t *testing.T) {
},
{
base: players[2],
serverKey: server.Key(),
bestRank: players[2].Rank(),
bestRankAt: now,
mostPoints: players[2].Points(),
@ -160,13 +209,14 @@ func TestNewCreatePlayerParams(t *testing.T) {
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()
return params.Base().ID() == expected.base.ID() && params.ServerKey() == expected.serverKey
})
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.serverKey, params.ServerKey(), "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)

View File

@ -0,0 +1,301 @@
package domain
import (
"cmp"
"fmt"
"math"
"slices"
"time"
)
type TribeChange struct {
id int
serverKey string
playerID int
oldTribeID int
newTribeID int
createdAt time.Time
}
const tribeChangeModelName = "TribeChange"
// UnmarshalTribeChangeFromDatabase unmarshals TribeChange from the database.
//
// It should be used only for unmarshalling from the database!
// You can't use UnmarshalTribeChangeFromDatabase as constructor - It may put domain into the invalid state!
func UnmarshalTribeChangeFromDatabase(
id int,
serverKey string,
playerID int,
oldTribeID int,
newTribeID int,
createdAt time.Time,
) (TribeChange, error) {
if err := validateIntInRange(id, 1, math.MaxInt); err != nil {
return TribeChange{}, ValidationError{
Model: tribeChangeModelName,
Field: "id",
Err: err,
}
}
if err := validateServerKey(serverKey); err != nil {
return TribeChange{}, ValidationError{
Model: tribeChangeModelName,
Field: "serverKey",
Err: err,
}
}
return TribeChange{
id: id,
playerID: playerID,
oldTribeID: oldTribeID,
newTribeID: newTribeID,
serverKey: serverKey,
createdAt: createdAt,
}, nil
}
func (tc TribeChange) ID() int {
return tc.id
}
func (tc TribeChange) PlayerID() int {
return tc.playerID
}
func (tc TribeChange) OldTribeID() int {
return tc.oldTribeID
}
func (tc TribeChange) NewTribeID() int {
return tc.newTribeID
}
func (tc TribeChange) ServerKey() string {
return tc.serverKey
}
func (tc TribeChange) CreatedAt() time.Time {
return tc.createdAt
}
type TribeChanges []TribeChange
type CreateTribeChangeParams struct {
serverKey string
playerID int
newTribeID int
oldTribeID int
}
const createTribeChangeParamsModelName = "CreateTribeChangeParams"
func NewCreateTribeChangeParams(
serverKey string,
playerID int,
oldTribeID int,
newTribeID int,
) (CreateTribeChangeParams, error) {
if err := validateServerKey(serverKey); err != nil {
return CreateTribeChangeParams{}, ValidationError{
Model: createTribeChangeParamsModelName,
Field: "serverKey",
Err: err,
}
}
if err := validateIntInRange(playerID, 1, math.MaxInt); err != nil {
return CreateTribeChangeParams{}, ValidationError{
Model: createTribeChangeParamsModelName,
Field: "playerID",
Err: err,
}
}
if err := validateIntInRange(oldTribeID, 0, math.MaxInt); err != nil {
return CreateTribeChangeParams{}, ValidationError{
Model: createTribeChangeParamsModelName,
Field: "oldTribeID",
Err: err,
}
}
if err := validateIntInRange(newTribeID, 0, math.MaxInt); err != nil {
return CreateTribeChangeParams{}, ValidationError{
Model: createTribeChangeParamsModelName,
Field: "newTribeID",
Err: err,
}
}
return CreateTribeChangeParams{
serverKey: serverKey,
playerID: playerID,
oldTribeID: oldTribeID,
newTribeID: newTribeID,
}, nil
}
// NewCreateTribeChangeParamsFromPlayers constructs a slice of CreatePlayerParams based on the given parameters.
// Both slices must be sorted in ascending order by ID
// + if storedPlayers contains players from different servers. they must be sorted in ascending order by server key.
func NewCreateTribeChangeParamsFromPlayers(
serverKey string,
players BasePlayers,
storedPlayers Players,
) ([]CreateTribeChangeParams, error) {
// tribe changes happens now and then, there is no point in prereallocating this slice
//nolint:prealloc
var params []CreateTribeChangeParams
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]
}
if (old.ID() > 0 && old.TribeID() == player.TribeID()) || (old.ID() == 0 && player.TribeID() == 0) {
continue
}
p, err := NewCreateTribeChangeParams(
serverKey,
player.ID(),
old.TribeID(),
player.TribeID(),
)
if err != nil {
return nil, err
}
params = append(params, p)
}
return params, nil
}
func (params CreateTribeChangeParams) ServerKey() string {
return params.serverKey
}
func (params CreateTribeChangeParams) PlayerID() int {
return params.playerID
}
func (params CreateTribeChangeParams) OldTribeID() int {
return params.oldTribeID
}
func (params CreateTribeChangeParams) NewTribeID() int {
return params.newTribeID
}
type TribeChangeSort uint8
const (
TribeChangeSortCreatedAtASC TribeChangeSort = iota + 1
TribeChangeSortCreatedAtDESC
TribeChangeSortServerKeyASC
TribeChangeSortServerKeyDESC
)
const TribeChangeListMaxLimit = 200
type ListTribeChangesParams struct {
serverKeys []string
sort []TribeChangeSort
limit int
offset int
}
const listTribeChangesParamsModelName = "ListTribeChangesParams"
func NewListTribeChangesParams() ListTribeChangesParams {
return ListTribeChangesParams{
sort: []TribeChangeSort{
TribeChangeSortServerKeyASC,
TribeChangeSortCreatedAtASC,
},
limit: TribeChangeListMaxLimit,
}
}
func (params *ListTribeChangesParams) ServerKeys() []string {
return params.serverKeys
}
func (params *ListTribeChangesParams) SetServerKeys(serverKeys []string) error {
params.serverKeys = serverKeys
return nil
}
func (params *ListTribeChangesParams) Sort() []TribeChangeSort {
return params.sort
}
const (
tribeChangeSortMinLength = 1
tribeChangeSortMaxLength = 2
)
func (params *ListTribeChangesParams) SetSort(sort []TribeChangeSort) error {
if err := validateSliceLen(sort, tribeChangeSortMinLength, tribeChangeSortMaxLength); err != nil {
return ValidationError{
Model: listTribeChangesParamsModelName,
Field: "sort",
Err: err,
}
}
params.sort = sort
return nil
}
func (params *ListTribeChangesParams) Limit() int {
return params.limit
}
func (params *ListTribeChangesParams) SetLimit(limit int) error {
if err := validateIntInRange(limit, 1, TribeChangeListMaxLimit); err != nil {
return ValidationError{
Model: listTribeChangesParamsModelName,
Field: "limit",
Err: err,
}
}
params.limit = limit
return nil
}
func (params *ListTribeChangesParams) Offset() int {
return params.offset
}
func (params *ListTribeChangesParams) SetOffset(offset int) error {
if err := validateIntInRange(offset, 0, math.MaxInt); err != nil {
return ValidationError{
Model: listTribeChangesParamsModelName,
Field: "offset",
Err: err,
}
}
params.offset = offset
return nil
}

View File

@ -0,0 +1,407 @@
package domain_test
import (
"cmp"
"fmt"
"slices"
"testing"
"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 TestNewCreateTribeChangeParams(t *testing.T) {
t.Parallel()
validTribeChange := domaintest.NewTribeChange(t)
type args struct {
serverKey string
playerID int
oldTribeID int
newTribeID int
}
type test struct {
name string
args args
expectedErr error
}
tests := []test{
{
name: "OK",
args: args{
serverKey: validTribeChange.ServerKey(),
playerID: validTribeChange.PlayerID(),
oldTribeID: validTribeChange.OldTribeID(),
newTribeID: validTribeChange.NewTribeID(),
},
},
{
name: "ERR: playerID < 1",
args: args{
serverKey: validTribeChange.ServerKey(),
playerID: 0,
oldTribeID: validTribeChange.OldTribeID(),
newTribeID: validTribeChange.NewTribeID(),
},
expectedErr: domain.ValidationError{
Model: "CreateTribeChangeParams",
Field: "playerID",
Err: domain.MinGreaterEqualError{
Min: 1,
Current: 0,
},
},
},
{
name: "ERR: oldTribeID < 0",
args: args{
serverKey: validTribeChange.ServerKey(),
playerID: validTribeChange.PlayerID(),
oldTribeID: -1,
newTribeID: validTribeChange.NewTribeID(),
},
expectedErr: domain.ValidationError{
Model: "CreateTribeChangeParams",
Field: "oldTribeID",
Err: domain.MinGreaterEqualError{
Min: 0,
Current: -1,
},
},
},
{
name: "ERR: newTribeID < 0",
args: args{
serverKey: validTribeChange.ServerKey(),
playerID: validTribeChange.PlayerID(),
oldTribeID: validTribeChange.OldTribeID(),
newTribeID: -1,
},
expectedErr: domain.ValidationError{
Model: "CreateTribeChangeParams",
Field: "newTribeID",
Err: domain.MinGreaterEqualError{
Min: 0,
Current: -1,
},
},
},
}
for _, serverKeyTest := range newServerKeyValidationTests() {
tests = append(tests, test{
name: serverKeyTest.name,
args: args{
serverKey: serverKeyTest.key,
playerID: validTribeChange.PlayerID(),
oldTribeID: validTribeChange.OldTribeID(),
newTribeID: validTribeChange.NewTribeID(),
},
expectedErr: domain.ValidationError{
Model: "CreateTribeChangeParams",
Field: "serverKey",
Err: serverKeyTest.expectedErr,
},
})
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
res, err := domain.NewCreateTribeChangeParams(
tt.args.serverKey,
tt.args.playerID,
tt.args.oldTribeID,
tt.args.newTribeID,
)
require.ErrorIs(t, err, tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.serverKey, res.ServerKey())
assert.Equal(t, tt.args.playerID, res.PlayerID())
assert.Equal(t, tt.args.oldTribeID, res.OldTribeID())
assert.Equal(t, tt.args.newTribeID, res.NewTribeID())
})
}
}
func TestNewCreateTribeChangeParamsFromPlayers(t *testing.T) {
t.Parallel()
server := domaintest.NewServer(t)
players := domain.BasePlayers{
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t),
domaintest.NewBasePlayer(t, func(cfg *domaintest.BasePlayerConfig) {
cfg.TribeID = 0
}),
}
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()
}),
// new tribe id
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = players[0].ID()
cfg.ServerKey = server.Key()
}),
// the same tribe id
domaintest.NewPlayer(t, func(cfg *domaintest.PlayerConfig) {
cfg.ID = players[1].ID()
cfg.ServerKey = server.Key()
cfg.TribeID = players[1].TribeID()
}),
}
expectedParams := []struct {
serverKey string
playerID int
oldTribeID int
newTribeID int
}{
{
serverKey: server.Key(),
playerID: players[0].ID(),
oldTribeID: storedPlayers[1].TribeID(),
newTribeID: players[0].TribeID(),
},
{
serverKey: server.Key(),
playerID: players[2].ID(),
oldTribeID: 0,
newTribeID: players[2].TribeID(),
},
}
slices.SortFunc(players, func(a, b domain.BasePlayer) int {
return cmp.Compare(a.ID(), b.ID())
})
slices.SortFunc(storedPlayers, 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())
})
res, err := domain.NewCreateTribeChangeParamsFromPlayers(server.Key(), players, storedPlayers)
require.NoError(t, err)
assert.Len(t, res, len(expectedParams))
for i, expected := range expectedParams {
idx := slices.IndexFunc(res, func(params domain.CreateTribeChangeParams) bool {
return params.PlayerID() == expected.playerID && params.ServerKey() == expected.serverKey
})
require.GreaterOrEqualf(t, idx, 0, "expectedParams[%d] not found", i)
params := res[idx]
assert.Equalf(t, expected.serverKey, params.ServerKey(), "expectedParams[%d]", i)
assert.Equalf(t, expected.playerID, params.PlayerID(), "expectedParams[%d]", i)
assert.Equalf(t, expected.newTribeID, params.NewTribeID(), "expectedParams[%d]", i)
assert.Equalf(t, expected.oldTribeID, params.OldTribeID(), "expectedParams[%d]", i)
}
}
func TestListTribeChangesParams_SetSort(t *testing.T) {
t.Parallel()
type args struct {
sort []domain.TribeChangeSort
}
tests := []struct {
name string
args args
expectedErr error
}{
{
name: "OK",
args: args{
sort: []domain.TribeChangeSort{
domain.TribeChangeSortCreatedAtASC,
domain.TribeChangeSortServerKeyASC,
},
},
},
{
name: "ERR: len(sort) < 1",
args: args{
sort: nil,
},
expectedErr: domain.ValidationError{
Model: "ListTribeChangesParams",
Field: "sort",
Err: domain.LenOutOfRangeError{
Min: 1,
Max: 2,
Current: 0,
},
},
},
{
name: "ERR: len(sort) > 2",
args: args{
sort: []domain.TribeChangeSort{
domain.TribeChangeSortCreatedAtASC,
domain.TribeChangeSortServerKeyASC,
domain.TribeChangeSortServerKeyDESC,
},
},
expectedErr: domain.ValidationError{
Model: "ListTribeChangesParams",
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.NewListTribeChangesParams()
require.ErrorIs(t, params.SetSort(tt.args.sort), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.sort, params.Sort())
})
}
}
func TestListTribeChangesParams_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.TribeChangeListMaxLimit,
},
},
{
name: "ERR: limit < 1",
args: args{
limit: 0,
},
expectedErr: domain.ValidationError{
Model: "ListTribeChangesParams",
Field: "limit",
Err: domain.MinGreaterEqualError{
Min: 1,
Current: 0,
},
},
},
{
name: fmt.Sprintf("ERR: limit > %d", domain.TribeChangeListMaxLimit),
args: args{
limit: domain.TribeChangeListMaxLimit + 1,
},
expectedErr: domain.ValidationError{
Model: "ListTribeChangesParams",
Field: "limit",
Err: domain.MaxLessEqualError{
Max: domain.TribeChangeListMaxLimit,
Current: domain.TribeChangeListMaxLimit + 1,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := domain.NewListTribeChangesParams()
require.ErrorIs(t, params.SetLimit(tt.args.limit), tt.expectedErr)
if tt.expectedErr != nil {
return
}
assert.Equal(t, tt.args.limit, params.Limit())
})
}
}
func TestListTribeChangesParams_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: "ListTribeChangesParams",
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.NewListTribeChangesParams()
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

@ -103,6 +103,7 @@ func TestNewCreateTribeParams(t *testing.T) {
expectedParams := []struct {
base domain.BaseTribe
serverKey string
bestRank int
bestRankAt time.Time
mostPoints int
@ -112,6 +113,7 @@ func TestNewCreateTribeParams(t *testing.T) {
}{
{
base: tribes[0],
serverKey: server.Key(),
bestRank: tribes[0].Rank(),
bestRankAt: now,
mostPoints: tribes[0].AllPoints(),
@ -121,6 +123,7 @@ func TestNewCreateTribeParams(t *testing.T) {
},
{
base: tribes[1],
serverKey: server.Key(),
bestRank: storedTribes[2].BestRank(),
bestRankAt: storedTribes[2].BestRankAt(),
mostPoints: storedTribes[2].MostPoints(),
@ -130,6 +133,7 @@ func TestNewCreateTribeParams(t *testing.T) {
},
{
base: tribes[2],
serverKey: server.Key(),
bestRank: tribes[2].Rank(),
bestRankAt: now,
mostPoints: tribes[2].AllPoints(),
@ -144,13 +148,14 @@ func TestNewCreateTribeParams(t *testing.T) {
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() && params.ServerKey() == server.Key()
return params.Base().ID() == expected.base.ID() && params.ServerKey() == expected.serverKey
})
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.serverKey, params.ServerKey(), "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)

View File

@ -11,7 +11,7 @@ func init() {
_, err := db.ExecContext(ctx, `
create table if not exists ennoblements
(
?,
?ID_COL,
server_key varchar(100) not null
references servers,
village_id bigint not null,
@ -40,7 +40,7 @@ create index if not exists ennoblements_server_key_new_tribe_id_idx
create index if not exists ennoblements_server_key_old_tribe_id_idx
on ennoblements (server_key, old_tribe_id);
`, bun.Safe(autoincrementIDColumn(db)))
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "drop table if exists ennoblements cascade;")

View File

@ -11,7 +11,7 @@ func init() {
_, err := db.ExecContext(ctx, `
create table if not exists player_snapshots
(
?,
?ID_COL,
player_id bigint not null,
num_villages bigint default 0,
points bigint default 0,
@ -32,7 +32,7 @@ create table if not exists player_snapshots
unique (player_id, server_key, date),
foreign key (player_id, server_key) references players
);
`, bun.Safe(autoincrementIDColumn(db)))
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "drop table if exists player_snapshots cascade;")

View File

@ -11,7 +11,7 @@ func init() {
_, err := db.ExecContext(ctx, `
create table if not exists tribe_snapshots
(
?,
?ID_COL,
tribe_id bigint not null,
server_key varchar(100) not null
references servers,
@ -34,7 +34,7 @@ create table if not exists tribe_snapshots
unique (tribe_id, server_key, date),
foreign key (tribe_id, server_key) references tribes
);
`, bun.Safe(autoincrementIDColumn(db)))
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "drop table if exists tribe_snapshots cascade;")

View File

@ -11,7 +11,7 @@ func init() {
_, err := db.ExecContext(ctx, `
create table if not exists tribe_changes
(
?,
?ID_COL,
player_id bigint not null,
new_tribe_id bigint,
old_tribe_id bigint,
@ -29,7 +29,7 @@ create index if not exists tribe_changes_server_key_new_tribe_id_idx
create index if not exists tribe_changes_server_key_old_tribe_id_idx
on tribe_changes (server_key, old_tribe_id);
`, bun.Safe(autoincrementIDColumn(db)))
`)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "drop table if exists tribe_changes cascade;")

View File

@ -9,19 +9,29 @@ import (
//nolint:lll
func init() {
// this index is for Postgres only
migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
if db.Dialect().Name() != dialect.PG {
return nil
}
var err error
_, err := db.ExecContext(
ctx,
`create unique index concurrently if not exists tribe_changes_hash_key
//nolint:exhaustive
switch db.Dialect().Name() {
case dialect.PG:
// hash_record_extended is Postgres specific
// https://dba.stackexchange.com/questions/299098/why-doesnt-my-unique-constraint-trigger/299107#299107
_, err = db.ExecContext(
ctx,
`create unique index concurrently if not exists tribe_changes_hash_key
on tribe_changes (hash_record_extended(
ROW (player_id, new_tribe_id, old_tribe_id, server_key, date_trunc('hours'::text, (created_at AT TIME ZONE 'UTC'::text))),
0::bigint));`,
)
)
case dialect.SQLite:
_, err = db.ExecContext(
ctx,
`create unique index if not exists tribe_changes_hash_key
on tribe_changes (server_key, coalesce(player_id, 0), coalesce(new_tribe_id, 0), coalesce(old_tribe_id, 0), strftime('%Y-%m-%d-%H', created_at));`,
)
}
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropIndex().IfExists().Index("tribe_changes_hash_key").Concurrently().Exec(ctx)

View File

@ -2,11 +2,31 @@ package migrations
import (
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/feature"
"github.com/uptrace/bun/migrate"
"github.com/uptrace/bun/schema"
)
var migrations = migrate.NewMigrations()
func NewMigrator(db *bun.DB, opts ...migrate.MigratorOption) *migrate.Migrator {
return migrate.NewMigrator(db, migrations, opts...)
return migrate.NewMigrator(
db.WithNamedArg("ID_COL", autoincrementIDColumn(db)),
migrations,
opts...,
)
}
type hasFeaturer interface {
HasFeature(feat feature.Feature) bool
}
func autoincrementIDColumn(f hasFeaturer) schema.QueryAppender {
if f.HasFeature(feature.GeneratedIdentity) {
// postgres
return bun.Safe("id bigint GENERATED BY DEFAULT AS IDENTITY primary key")
}
// sqlite
return bun.Safe("id INTEGER PRIMARY KEY")
}

View File

@ -1,19 +0,0 @@
package migrations
import (
"github.com/uptrace/bun/dialect/feature"
)
type hasFeaturer interface {
HasFeature(feat feature.Feature) bool
}
func autoincrementIDColumn(f hasFeaturer) string {
if f.HasFeature(feature.GeneratedIdentity) {
// postgres
return "id bigint GENERATED BY DEFAULT AS IDENTITY primary key"
}
// sqlite
return "id INTEGER PRIMARY KEY"
}