diff --git a/cmd/twhelp/cmd_consumer.go b/cmd/twhelp/cmd_consumer.go index e80d116..cf93cda 100644 --- a/cmd/twhelp/cmd_consumer.go +++ b/cmd/twhelp/cmd_consumer.go @@ -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, diff --git a/internal/adapter/adaptertest/fixture.go b/internal/adapter/adaptertest/fixture.go index fb48334..4de9039 100644 --- a/internal/adapter/adaptertest/fixture.go +++ b/internal/adapter/adaptertest/fixture.go @@ -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), diff --git a/internal/adapter/adaptertest/sqlite.go b/internal/adapter/adaptertest/sqlite.go index 62f680b..3db1087 100644 --- a/internal/adapter/adaptertest/sqlite.go +++ b/internal/adapter/adaptertest/sqlite.go @@ -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()) diff --git a/internal/adapter/internal/bunmodel/tribe_change.go b/internal/adapter/internal/bunmodel/tribe_change.go new file mode 100644 index 0000000..7e21a0f --- /dev/null +++ b/internal/adapter/internal/bunmodel/tribe_change.go @@ -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 +} diff --git a/internal/adapter/repository_bun_player.go b/internal/adapter/repository_bun_player.go index a2e3d67..a0ab2d3 100644 --- a/internal/adapter/repository_bun_player.go +++ b/internal/adapter/repository_bun_player.go @@ -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()), }) } diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index c1e362a..8992108 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -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, }) } diff --git a/internal/adapter/repository_bun_tribe.go b/internal/adapter/repository_bun_tribe.go index 7d663a3..2592c1e 100644 --- a/internal/adapter/repository_bun_tribe.go +++ b/internal/adapter/repository_bun_tribe.go @@ -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()), }) } diff --git a/internal/adapter/repository_bun_tribe_change.go b/internal/adapter/repository_bun_tribe_change.go new file mode 100644 index 0000000..7639d34 --- /dev/null +++ b/internal/adapter/repository_bun_tribe_change.go @@ -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()) +} diff --git a/internal/adapter/repository_bun_tribe_change_test.go b/internal/adapter/repository_bun_tribe_change_test.go new file mode 100644 index 0000000..b70e91a --- /dev/null +++ b/internal/adapter/repository_bun_tribe_change_test.go @@ -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)) + }) +} diff --git a/internal/adapter/repository_bun_village.go b/internal/adapter/repository_bun_village.go index 8c5265a..7cd37a6 100644 --- a/internal/adapter/repository_bun_village.go +++ b/internal/adapter/repository_bun_village.go @@ -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, }) } diff --git a/internal/adapter/repository_ennoblement_test.go b/internal/adapter/repository_ennoblement_test.go index d7e1c87..78e8b3d 100644 --- a/internal/adapter/repository_ennoblement_test.go +++ b/internal/adapter/repository_ennoblement_test.go @@ -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) { diff --git a/internal/adapter/repository_test.go b/internal/adapter/repository_test.go index 8058cdd..8766a6d 100644 --- a/internal/adapter/repository_test.go +++ b/internal/adapter/repository_test.go @@ -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), } } diff --git a/internal/adapter/repository_tribe_change_test.go b/internal/adapter/repository_tribe_change_test.go new file mode 100644 index 0000000..ae9a4e1 --- /dev/null +++ b/internal/adapter/repository_tribe_change_test.go @@ -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) + }) + } + }) +} diff --git a/internal/adapter/testdata/fixture.yml b/internal/adapter/testdata/fixture.yml index 07e2ba9..c45504a 100644 --- a/internal/adapter/testdata/fixture.yml +++ b/internal/adapter/testdata/fixture.yml @@ -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 diff --git a/internal/app/service_player.go b/internal/app/service_player.go index 91293f5..82cf0a1 100644 --- a/internal/app/service_player.go +++ b/internal/app/service_player.go @@ -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...) } diff --git a/internal/app/service_tribe_change.go b/internal/app/service_tribe_change.go new file mode 100644 index 0000000..c856eb0 --- /dev/null +++ b/internal/app/service_tribe_change.go @@ -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 +} diff --git a/internal/domain/domaintest/player.go b/internal/domain/domaintest/player.go index 00ae664..2d7b7af 100644 --- a/internal/domain/domaintest/player.go +++ b/internal/domain/domaintest/player.go @@ -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) diff --git a/internal/domain/domaintest/tribe.go b/internal/domain/domaintest/tribe.go index b915964..aac582c 100644 --- a/internal/domain/domaintest/tribe.go +++ b/internal/domain/domaintest/tribe.go @@ -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) diff --git a/internal/domain/domaintest/tribe_change.go b/internal/domain/domaintest/tribe_change.go new file mode 100644 index 0000000..c2b1e8f --- /dev/null +++ b/internal/domain/domaintest/tribe_change.go @@ -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 +} diff --git a/internal/domain/player.go b/internal/domain/player.go index 7c0cf57..c04ce50 100644 --- a/internal/domain/player.go +++ b/internal/domain/player.go @@ -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 { diff --git a/internal/domain/player_test.go b/internal/domain/player_test.go index 8f0f97d..2a4da81 100644 --- a/internal/domain/player_test.go +++ b/internal/domain/player_test.go @@ -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) diff --git a/internal/domain/tribe_change.go b/internal/domain/tribe_change.go new file mode 100644 index 0000000..b0a788f --- /dev/null +++ b/internal/domain/tribe_change.go @@ -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 +} diff --git a/internal/domain/tribe_change_test.go b/internal/domain/tribe_change_test.go new file mode 100644 index 0000000..6b81023 --- /dev/null +++ b/internal/domain/tribe_change_test.go @@ -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()) + }) + } +} diff --git a/internal/domain/tribe_test.go b/internal/domain/tribe_test.go index 988c5c7..2634161 100644 --- a/internal/domain/tribe_test.go +++ b/internal/domain/tribe_test.go @@ -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) diff --git a/internal/migrations/20231220051322_create_ennoblements_table.go b/internal/migrations/20231220051322_create_ennoblements_table.go index 1d07d7a..a39468f 100644 --- a/internal/migrations/20231220051322_create_ennoblements_table.go +++ b/internal/migrations/20231220051322_create_ennoblements_table.go @@ -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;") diff --git a/internal/migrations/20231220051411_create_player_snapshots_table.go b/internal/migrations/20231220051411_create_player_snapshots_table.go index e4a2d67..78574f5 100644 --- a/internal/migrations/20231220051411_create_player_snapshots_table.go +++ b/internal/migrations/20231220051411_create_player_snapshots_table.go @@ -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;") diff --git a/internal/migrations/20231220052323_create_tribe_snapshots_table.go b/internal/migrations/20231220052323_create_tribe_snapshots_table.go index 1e8cd70..a4ed189 100644 --- a/internal/migrations/20231220052323_create_tribe_snapshots_table.go +++ b/internal/migrations/20231220052323_create_tribe_snapshots_table.go @@ -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;") diff --git a/internal/migrations/20231220052428_create_tribe_changes_table.go b/internal/migrations/20231220052428_create_tribe_changes_table.go index b6c88c3..bbd36f4 100644 --- a/internal/migrations/20231220052428_create_tribe_changes_table.go +++ b/internal/migrations/20231220052428_create_tribe_changes_table.go @@ -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;") diff --git a/internal/migrations/20231220052547_create_index_tribe_changes_hash_key.go b/internal/migrations/20231220052547_create_index_tribe_changes_hash_key.go index 07996b8..a1e56e6 100644 --- a/internal/migrations/20231220052547_create_index_tribe_changes_hash_key.go +++ b/internal/migrations/20231220052547_create_index_tribe_changes_hash_key.go @@ -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) diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 281611a..fa46505 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -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") } diff --git a/internal/migrations/sql_utils.go b/internal/migrations/sql_utils.go deleted file mode 100644 index 0ccdbac..0000000 --- a/internal/migrations/sql_utils.go +++ /dev/null @@ -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" -}