diff --git a/cmd/twhelp/cmd_consumer.go b/cmd/twhelp/cmd_consumer.go index 0b8fa1a..faf2df6 100644 --- a/cmd/twhelp/cmd_consumer.go +++ b/cmd/twhelp/cmd_consumer.go @@ -150,6 +150,42 @@ var cmdConsumer = &cli.Command{ ) consumer.Register(router) + return nil + }, + ) + }, + }, + { + Name: "village", + Usage: "Run the worker responsible for consuming village-related messages", + Flags: concatSlices(dbFlags, rmqFlags, twSvcFlags), + Action: func(c *cli.Context) error { + return runConsumer( + c, + "VillageConsumer", + func( + c *cli.Context, + router *message.Router, + logger watermill.LoggerAdapter, + publisher *amqp.Publisher, + subscriber *amqp.Subscriber, + marshaler watermillmsg.Marshaler, + db *bun.DB, + ) error { + twSvc, err := newTWServiceFromFlags(c) + if err != nil { + return err + } + + consumer := port.NewVillageWatermillConsumer( + app.NewVillageService(adapter.NewVillageBunRepository(db), twSvc), + subscriber, + logger, + marshaler, + c.String(rmqFlagTopicServerSyncedEvent.Name), + ) + consumer.Register(router) + return nil }, ) diff --git a/internal/adapter/adaptertest/fixture.go b/internal/adapter/adaptertest/fixture.go index 9507c41..223b036 100644 --- a/internal/adapter/adaptertest/fixture.go +++ b/internal/adapter/adaptertest/fixture.go @@ -21,6 +21,7 @@ func NewFixture(bunDB *bun.DB) *Fixture { (*bunmodel.Server)(nil), (*bunmodel.Tribe)(nil), (*bunmodel.Player)(nil), + (*bunmodel.Village)(nil), ) return &Fixture{ f: dbfixture.New(bunDB), diff --git a/internal/adapter/http_tw.go b/internal/adapter/http_tw.go index a5ebc4e..13ddcbe 100644 --- a/internal/adapter/http_tw.go +++ b/internal/adapter/http_tw.go @@ -203,12 +203,12 @@ func (t *TWHTTP) convertTribesToDomain(tribes []tw.Tribe) (domain.BaseTribes, er } func (t *TWHTTP) GetPlayers(ctx context.Context, baseURL *url.URL) (domain.BasePlayers, error) { - tribes, err := t.client.GetPlayers(ctx, baseURL) + players, err := t.client.GetPlayers(ctx, baseURL) if err != nil { return nil, err } - return t.convertPlayersToDomain(tribes) + return t.convertPlayersToDomain(players) } func (t *TWHTTP) convertPlayersToDomain(players []tw.Player) (domain.BasePlayers, error) { @@ -248,3 +248,37 @@ func (t *TWHTTP) convertPlayersToDomain(players []tw.Player) (domain.BasePlayers return res, nil } + +func (t *TWHTTP) GetVillages(ctx context.Context, baseURL *url.URL) (domain.BaseVillages, error) { + villages, err := t.client.GetVillages(ctx, baseURL) + if err != nil { + return nil, err + } + + return t.convertVillagesToDomain(villages) +} + +func (t *TWHTTP) convertVillagesToDomain(villages []tw.Village) (domain.BaseVillages, error) { + res := make(domain.BaseVillages, 0, len(villages)) + + for _, v := range villages { + converted, err := domain.NewBaseVillage( + v.ID, + v.Name, + v.Points, + v.X, + v.Y, + v.Continent, + v.Bonus, + v.PlayerID, + v.ProfileURL, + ) + if err != nil { + return nil, fmt.Errorf("couldn't construct domain.BaseVillage: %w", err) + } + + res = append(res, converted) + } + + return res, nil +} diff --git a/internal/adapter/internal/bunmodel/village.go b/internal/adapter/internal/bunmodel/village.go new file mode 100644 index 0000000..e749dc7 --- /dev/null +++ b/internal/adapter/internal/bunmodel/village.go @@ -0,0 +1,69 @@ +package bunmodel + +import ( + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/uptrace/bun" +) + +type Village struct { + bun.BaseModel `bun:"table:villages,alias:village"` + + ID int `bun:"id,nullzero,pk"` + ServerKey string `bun:"server_key,nullzero,pk"` + Name string `bun:"name,nullzero"` + Points int `bun:"points"` + X int `bun:"x"` + Y int `bun:"y"` + Continent string `bun:"continent"` + Bonus int `bun:"bonus"` + PlayerID int `bun:"player_id,nullzero"` + Player Player `bun:"player,rel:belongs-to,join:player_id=id,join:server_key=server_key"` + ProfileURL string `bun:"profile_url,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero"` +} + +func (v Village) ToDomain() (domain.Village, error) { + converted, err := domain.UnmarshalVillageFromDatabase( + v.ID, + v.ServerKey, + v.Name, + v.Points, + v.X, + v.Y, + v.Continent, + v.Bonus, + v.PlayerID, + v.ProfileURL, + v.CreatedAt, + ) + if err != nil { + return domain.Village{}, fmt.Errorf( + "couldn't construct domain.Village (id=%d,serverKey=%s): %w", + v.ID, + v.ServerKey, + err, + ) + } + + return converted, nil +} + +type Villages []Village + +func (vs Villages) ToDomain() (domain.Villages, error) { + res := make(domain.Villages, 0, len(vs)) + + for _, v := range vs { + converted, err := v.ToDomain() + if err != nil { + return nil, err + } + + res = append(res, converted) + } + + return res, nil +} diff --git a/internal/adapter/repository_bun_village.go b/internal/adapter/repository_bun_village.go new file mode 100644 index 0000000..8c5265a --- /dev/null +++ b/internal/adapter/repository_bun_village.go @@ -0,0 +1,142 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/adapter/internal/bunmodel" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +type VillageBunRepository struct { + db bun.IDB +} + +func NewVillageBunRepository(db bun.IDB) *VillageBunRepository { + return &VillageBunRepository{db: db} +} + +func (repo *VillageBunRepository) CreateOrUpdate(ctx context.Context, params ...domain.CreateVillageParams) error { + if len(params) == 0 { + return nil + } + + villages := make(bunmodel.Villages, 0, len(params)) + + for _, p := range params { + base := p.Base() + villages = append(villages, bunmodel.Village{ + ID: base.ID(), + ServerKey: p.ServerKey(), + Name: base.Name(), + Points: base.Points(), + X: base.X(), + Y: base.Y(), + Continent: base.Continent(), + Bonus: base.Bonus(), + PlayerID: base.PlayerID(), + ProfileURL: base.ProfileURL().String(), + CreatedAt: time.Now(), + }) + } + + q := repo.db.NewInsert(). + Model(&villages) + + //nolint:exhaustive + switch q.Dialect().Name() { + case dialect.PG: + q = q.On("CONFLICT ON CONSTRAINT villages_pkey DO UPDATE") + case dialect.SQLite: + q = q.On("CONFLICT(id, server_key) DO UPDATE") + default: + q = q.Err(errors.New("unsupported dialect")) + } + + if _, err := q. + Set("name = EXCLUDED.name"). + Set("points = EXCLUDED.points"). + Set("x = EXCLUDED.x"). + Set("y = EXCLUDED.y"). + Set("continent = EXCLUDED.continent"). + Set("bonus = EXCLUDED.bonus"). + Set("player_id = EXCLUDED.player_id"). + Set("profile_url = EXCLUDED.profile_url"). + Returning(""). + Exec(ctx); err != nil { + return fmt.Errorf("something went wrong while inserting villages into the db: %w", err) + } + + return nil +} + +func (repo *VillageBunRepository) List(ctx context.Context, params domain.ListVillagesParams) (domain.Villages, error) { + var villages bunmodel.Villages + + if err := repo.db.NewSelect(). + Model(&villages). + Apply(listVillagesParamsApplier{params: params}.apply). + Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("couldn't select villages from the db: %w", err) + } + + return villages.ToDomain() +} + +func (repo *VillageBunRepository) Delete(ctx context.Context, serverKey string, ids ...int) error { + if len(ids) == 0 { + return nil + } + + if _, err := repo.db.NewDelete(). + Model((*bunmodel.Village)(nil)). + Where("id IN (?)", bun.In(ids)). + Where("server_key = ?", serverKey). + Returning(""). + Exec(ctx); err != nil { + return fmt.Errorf("couldn't delete villages: %w", err) + } + + return nil +} + +type listVillagesParamsApplier struct { + params domain.ListVillagesParams +} + +//nolint:gocyclo +func (a listVillagesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { + if ids := a.params.IDs(); len(ids) > 0 { + q = q.Where("village.id IN (?)", bun.In(ids)) + } + + if idGT := a.params.IDGT(); idGT.Valid { + q = q.Where("village.id > ?", idGT.Value) + } + + if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 { + q = q.Where("village.server_key IN (?)", bun.In(serverKeys)) + } + + for _, s := range a.params.Sort() { + switch s { + case domain.VillageSortIDASC: + q = q.Order("village.id ASC") + case domain.VillageSortIDDESC: + q = q.Order("village.id DESC") + case domain.VillageSortServerKeyASC: + q = q.Order("village.server_key ASC") + case domain.VillageSortServerKeyDESC: + q = q.Order("village.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_village_test.go b/internal/adapter/repository_bun_village_test.go new file mode 100644 index 0000000..484e09a --- /dev/null +++ b/internal/adapter/repository_bun_village_test.go @@ -0,0 +1,29 @@ +package adapter_test + +import ( + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/adapter/adaptertest" +) + +func TestVillageBunRepository_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long-running test") + } + + testVillageRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, postgres.NewBunDB(t)) + }) +} + +func TestVillageBunRepository_SQLite(t *testing.T) { + t.Parallel() + + testVillageRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, adaptertest.NewBunDBSQLite(t)) + }) +} diff --git a/internal/adapter/repository_test.go b/internal/adapter/repository_test.go index 9768a11..2efbf4d 100644 --- a/internal/adapter/repository_test.go +++ b/internal/adapter/repository_test.go @@ -37,11 +37,18 @@ type playerRepository interface { Delete(ctx context.Context, serverKey string, ids ...int) error } +type villageRepository interface { + CreateOrUpdate(ctx context.Context, params ...domain.CreateVillageParams) error + List(ctx context.Context, params domain.ListVillagesParams) (domain.Villages, error) + Delete(ctx context.Context, serverKey string, ids ...int) error +} + type repositories struct { version versionRepository server serverRepository tribe tribeRepository player playerRepository + village villageRepository } func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { @@ -54,5 +61,6 @@ func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { server: adapter.NewServerBunRepository(bunDB), tribe: adapter.NewTribeBunRepository(bunDB), player: adapter.NewPlayerBunRepository(bunDB), + village: adapter.NewVillageBunRepository(bunDB), } } diff --git a/internal/adapter/repository_village_test.go b/internal/adapter/repository_village_test.go new file mode 100644 index 0000000..24ac104 --- /dev/null +++ b/internal/adapter/repository_village_test.go @@ -0,0 +1,317 @@ +package adapter_test + +import ( + "cmp" + "context" + "fmt" + "math" + "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 testVillageRepository(t *testing.T, newRepos func(t *testing.T) repositories) { + t.Helper() + + ctx := context.Background() + + t.Run("CreateOrUpdate", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + assertCreatedUpdated := func(t *testing.T, params []domain.CreateVillageParams) { + t.Helper() + + require.NotEmpty(t, params) + + ids := make([]int, 0, len(params)) + for _, p := range params { + ids = append(ids, p.Base().ID()) + } + + listParams := domain.NewListVillagesParams() + require.NoError(t, listParams.SetIDs(ids)) + require.NoError(t, listParams.SetServerKeys([]string{params[0].ServerKey()})) + + villages, err := repos.village.List(ctx, listParams) + require.NoError(t, err) + assert.Len(t, villages, len(params)) + for i, p := range params { + idx := slices.IndexFunc(villages, func(village domain.Village) bool { + return village.ID() == p.Base().ID() && village.ServerKey() == p.ServerKey() + }) + require.GreaterOrEqualf(t, idx, 0, "params[%d]", i) + village := villages[idx] + + assert.Equalf(t, p.Base(), village.Base(), "params[%d]", i) + assert.Equalf(t, p.ServerKey(), village.ServerKey(), "params[%d]", i) + } + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + servers, err := repos.server.List(ctx, domain.NewListServersParams()) + require.NoError(t, err) + require.NotEmpty(t, servers) + server := servers[0] + + villagesToCreate := domain.BaseVillages{ + domaintest.NewBaseVillage(t), + domaintest.NewBaseVillage(t), + } + + createParams, err := domain.NewCreateVillageParams(server.Key(), villagesToCreate) + require.NoError(t, err) + + require.NoError(t, repos.village.CreateOrUpdate(ctx, createParams...)) + assertCreatedUpdated(t, createParams) + + villagesToUpdate := domain.BaseVillages{ + domaintest.NewBaseVillage(t, func(cfg *domaintest.BaseVillageConfig) { + cfg.ID = villagesToCreate[0].ID() + }), + } + + updateParams, err := domain.NewCreateVillageParams(server.Key(), villagesToUpdate) + require.NoError(t, err) + + require.NoError(t, repos.village.CreateOrUpdate(ctx, updateParams...)) + assertCreatedUpdated(t, updateParams) + }) + + t.Run("OK: len(params) == 0", func(t *testing.T) { + t.Parallel() + + require.NoError(t, repos.village.CreateOrUpdate(ctx)) + }) + }) + + t.Run("List & ListCount", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + villages, listVillagesErr := repos.village.List(ctx, domain.NewListVillagesParams()) + require.NoError(t, listVillagesErr) + require.NotEmpty(t, villages) + randVillage := villages[0] + + tests := []struct { + name string + params func(t *testing.T) domain.ListVillagesParams + assertVillages func(t *testing.T, params domain.ListVillagesParams, villages domain.Villages) + assertError func(t *testing.T, err error) + assertTotal func(t *testing.T, params domain.ListVillagesParams, total int) + }{ + { + name: "OK: default params", + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + return domain.NewListVillagesParams() + }, + assertVillages: func(t *testing.T, params domain.ListVillagesParams, villages domain.Villages) { + t.Helper() + assert.NotEmpty(t, len(villages)) + assert.True(t, slices.IsSortedFunc(villages, func(a, b domain.Village) int { + if x := cmp.Compare(a.ServerKey(), b.ServerKey()); x != 0 { + return x + } + return cmp.Compare(a.ID(), b.ID()) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListVillagesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: sort=[serverKey DESC, id DESC]", + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + params := domain.NewListVillagesParams() + require.NoError(t, params.SetSort([]domain.VillageSort{domain.VillageSortServerKeyDESC, domain.VillageSortIDDESC})) + return params + }, + assertVillages: func(t *testing.T, params domain.ListVillagesParams, villages domain.Villages) { + t.Helper() + assert.NotEmpty(t, len(villages)) + assert.True(t, slices.IsSortedFunc(villages, func(a, b domain.Village) int { + if x := cmp.Compare(a.ServerKey(), b.ServerKey()) * -1; x != 0 { + return x + } + return cmp.Compare(a.ID(), b.ID()) * -1 + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListVillagesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: fmt.Sprintf("OK: ids=[%d] serverKeys=[%s]", randVillage.ID(), randVillage.ServerKey()), + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + params := domain.NewListVillagesParams() + require.NoError(t, params.SetIDs([]int{randVillage.ID()})) + require.NoError(t, params.SetServerKeys([]string{randVillage.ServerKey()})) + return params + }, + assertVillages: func(t *testing.T, params domain.ListVillagesParams, villages domain.Villages) { + t.Helper() + + ids := params.IDs() + serverKeys := params.ServerKeys() + + for _, v := range villages { + assert.True(t, slices.Contains(ids, v.ID())) + assert.True(t, slices.Contains(serverKeys, v.ServerKey())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListVillagesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: fmt.Sprintf("OK: idGT=%d", randVillage.ID()), + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + params := domain.NewListVillagesParams() + require.NoError(t, params.SetIDGT(domain.NullInt{ + Value: randVillage.ID(), + Valid: true, + })) + return params + }, + assertVillages: func(t *testing.T, params domain.ListVillagesParams, villages domain.Villages) { + t.Helper() + assert.NotEmpty(t, villages) + for _, v := range villages { + assert.Greater(t, v.ID(), params.IDGT().Value, v.ID()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListVillagesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: offset=1 limit=2", + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + params := domain.NewListVillagesParams() + require.NoError(t, params.SetOffset(1)) + require.NoError(t, params.SetLimit(2)) + return params + }, + assertVillages: func(t *testing.T, params domain.ListVillagesParams, villages domain.Villages) { + t.Helper() + assert.Len(t, villages, params.Limit()) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListVillagesParams, 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.village.List(ctx, params) + tt.assertError(t, err) + tt.assertVillages(t, params, res) + }) + } + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + listServersParams := domain.NewListServersParams() + require.NoError(t, listServersParams.SetSpecial(domain.NullBool{Value: false, Valid: true})) + servers, listServersErr := repos.server.List(ctx, listServersParams) + require.NoError(t, listServersErr) + require.NotEmpty(t, servers) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + serverKeys := make([]string, 0, len(servers)) + for _, s := range servers { + serverKeys = append(serverKeys, s.Key()) + } + + listVillagesParams := domain.NewListVillagesParams() + require.NoError(t, listVillagesParams.SetServerKeys(serverKeys)) + + villagesBeforeDelete, err := repos.village.List(ctx, listVillagesParams) + require.NoError(t, err) + + var serverKey string + var ids []int + + for _, v := range villagesBeforeDelete { + if serverKey == "" { + serverKey = v.ServerKey() + } + + if v.ServerKey() == serverKey { + ids = append(ids, v.ID()) + } + } + + idsToDelete := ids[:int(math.Ceil(float64(len(ids))/2))] + + require.NoError(t, repos.village.Delete(ctx, serverKey, idsToDelete...)) + + villagesAfterDelete, err := repos.village.List(ctx, listVillagesParams) + require.NoError(t, err) + assert.Len(t, villagesAfterDelete, len(villagesBeforeDelete)-len(idsToDelete)) + for _, v := range villagesAfterDelete { + if v.ServerKey() == serverKey && slices.Contains(ids, v.ID()) { + assert.False(t, slices.Contains(idsToDelete, v.ID())) + } + } + }) + + t.Run("OK: len(ids) == 0", func(t *testing.T) { + t.Parallel() + + require.NoError(t, repos.village.Delete(ctx, servers[0].Key())) + }) + }) +} diff --git a/internal/adapter/testdata/fixture.yml b/internal/adapter/testdata/fixture.yml index fc7dd2b..0caec7e 100644 --- a/internal/adapter/testdata/fixture.yml +++ b/internal/adapter/testdata/fixture.yml @@ -7202,3 +7202,89 @@ tribe_id: 2 created_at: 2021-09-17T09:01:00Z profile_url: http://www.dynamicbleeding-edge.info/synergies/dynamic/productize/relationships +- model: Village + rows: + - _id: pl169-village-1 + id: 1111 + server_key: pl169 + name: Village 1 + points: 12154 + x: 327 + y: 428 + continent: K43 + bonus: 0 + player_id: 699783765 + created_at: 2021-09-17T09:01:00.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_village&id=1111 + - _id: pl169-village-2 + id: 1112 + server_key: pl169 + name: Village 2 + points: 12154 + x: 531 + y: 448 + continent: K45 + bonus: 1 + player_id: 699783765 + created_at: 2021-09-19T09:01:00.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_village&id=1112 + - _id: pl169-village-3 + id: 1113 + server_key: pl169 + name: Village 3 + points: 2500 + x: 532 + y: 448 + continent: K45 + bonus: 0 + player_id: 0 + created_at: 2021-09-30T15:11:00.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_village&id=1113 + - _id: pl169-village-4 + id: 1114 + server_key: pl169 + name: Village 4 + points: 2500 + x: 533 + y: 448 + continent: K45 + bonus: 4 + player_id: 0 + created_at: 2021-09-29T05:11:00.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_village&id=1114 + - _id: it70-village-1 + id: 10022 + server_key: it70 + name: Village 1 + points: 12154 + x: 533 + y: 548 + continent: K55 + bonus: 4 + player_id: 578014 + created_at: 2022-02-21T18:00:10.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_village&id=10022 + - _id: it70-village-2 + id: 10023 + server_key: it70 + name: Village 2 + points: 12154 + x: 633 + y: 548 + continent: K56 + bonus: 0 + player_id: 578014 + created_at: 2022-02-22T15:00:10.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_village&id=10023 + - _id: it70-village-3 + id: 10024 + server_key: it70 + name: Village 3 + points: 12154 + x: 100 + y: 100 + continent: K11 + bonus: 0 + player_id: 0 + created_at: 2022-02-25T15:00:10.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_village&id=10024 diff --git a/internal/app/service_tw.go b/internal/app/service_tw.go index a9e243d..e5bcc73 100644 --- a/internal/app/service_tw.go +++ b/internal/app/service_tw.go @@ -14,4 +14,5 @@ type TWService interface { GetBuildingInfo(ctx context.Context, baseURL *url.URL) (domain.BuildingInfo, error) GetTribes(ctx context.Context, baseURL *url.URL) (domain.BaseTribes, error) GetPlayers(ctx context.Context, baseURL *url.URL) (domain.BasePlayers, error) + GetVillages(ctx context.Context, baseURL *url.URL) (domain.BaseVillages, error) } diff --git a/internal/app/service_village.go b/internal/app/service_village.go new file mode 100644 index 0000000..c63bb56 --- /dev/null +++ b/internal/app/service_village.go @@ -0,0 +1,110 @@ +package app + +import ( + "context" + "fmt" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" +) + +type VillageRepository interface { + CreateOrUpdate(ctx context.Context, params ...domain.CreateVillageParams) error + List(ctx context.Context, params domain.ListVillagesParams) (domain.Villages, error) + Delete(ctx context.Context, serverKey string, ids ...int) error +} + +type VillageService struct { + repo VillageRepository + twSvc TWService +} + +func NewVillageService(repo VillageRepository, twSvc TWService) *VillageService { + return &VillageService{repo: repo, twSvc: twSvc} +} + +func (svc *VillageService) Sync(ctx context.Context, serverSyncedPayload domain.ServerSyncedEventPayload) error { + serverKey := serverSyncedPayload.Key() + serverURL := serverSyncedPayload.URL() + + villages, err := svc.twSvc.GetVillages(ctx, serverURL) + if err != nil { + return fmt.Errorf("%s: couldn't get villages: %w", serverKey, err) + } + + if err = svc.createOrUpdate(ctx, serverKey, villages); err != nil { + return fmt.Errorf("%s: couldn't create/update villages: %w", serverKey, err) + } + + if err = svc.delete(ctx, serverKey, villages); err != nil { + return fmt.Errorf("%s: couldn't delete villages: %w", serverKey, err) + } + + return nil +} + +const villageCreateOrUpdateChunkSize = domain.VillageListMaxLimit + +func (svc *VillageService) createOrUpdate(ctx context.Context, serverKey string, villages domain.BaseVillages) error { + for i := 0; i < len(villages); i += villageCreateOrUpdateChunkSize { + end := i + villageCreateOrUpdateChunkSize + if end > len(villages) { + end = len(villages) + } + + if err := svc.createOrUpdateChunk(ctx, serverKey, villages[i:end]); err != nil { + return err + } + } + + return nil +} + +func (svc *VillageService) createOrUpdateChunk( + ctx context.Context, + serverKey string, + villages domain.BaseVillages, +) error { + createParams, err := domain.NewCreateVillageParams(serverKey, villages) + if err != nil { + return err + } + + return svc.repo.CreateOrUpdate(ctx, createParams...) +} + +func (svc *VillageService) delete(ctx context.Context, serverKey string, villages domain.BaseVillages) error { + listParams := domain.NewListVillagesParams() + if err := listParams.SetServerKeys([]string{serverKey}); err != nil { + return err + } + if err := listParams.SetSort([]domain.VillageSort{domain.VillageSortIDASC}); err != nil { + return err + } + if err := listParams.SetLimit(domain.VillageListMaxLimit); err != nil { + return err + } + + var toDelete []int + + for { + storedVillages, err := svc.repo.List(ctx, listParams) + if err != nil { + return err + } + + if len(storedVillages) == 0 { + break + } + + toDelete = append(toDelete, storedVillages.Delete(villages)...) + + if err = listParams.SetIDGT(domain.NullInt{ + Value: storedVillages[len(storedVillages)-1].ID(), + Valid: true, + }); err != nil { + return err + } + } + + return svc.repo.Delete(ctx, serverKey, toDelete...) +} diff --git a/internal/domain/base_village.go b/internal/domain/base_village.go new file mode 100644 index 0000000..5bd00d9 --- /dev/null +++ b/internal/domain/base_village.go @@ -0,0 +1,157 @@ +package domain + +import ( + "math" + "net/url" +) + +type BaseVillage struct { + id int + name string + points int + x int + y int + continent string + bonus int + playerID int + profileURL *url.URL +} + +const baseVillageModelName = "BaseVillage" + +func NewBaseVillage( + id int, + name string, + points int, + x, y int, + continent string, + bonus int, + playerID int, + profileURL *url.URL, +) (BaseVillage, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "id", + Err: err, + } + } + + if err := validateStringLen(name, villageNameMinLength, villageNameMaxLength); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "name", + Err: err, + } + } + + if err := validateIntInRange(points, 1, math.MaxInt); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "points", + Err: err, + } + } + + if err := validateIntInRange(x, 0, math.MaxInt); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "x", + Err: err, + } + } + + if err := validateIntInRange(y, 0, math.MaxInt); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "y", + Err: err, + } + } + + if err := validateStringLen(continent, villageContinentMinLength, villageContinentMaxLength); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "continent", + Err: err, + } + } + + if err := validateIntInRange(bonus, 0, math.MaxInt); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "bonus", + Err: err, + } + } + + if err := validateIntInRange(playerID, 0, math.MaxInt); err != nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "playerID", + Err: err, + } + } + + if profileURL == nil { + return BaseVillage{}, ValidationError{ + Model: baseVillageModelName, + Field: "profileURL", + Err: ErrNil, + } + } + + return BaseVillage{ + id: id, + name: name, + points: points, + x: x, + y: y, + continent: continent, + bonus: bonus, + playerID: playerID, + profileURL: profileURL, + }, nil +} + +func (v BaseVillage) ID() int { + return v.id +} + +func (v BaseVillage) Name() string { + return v.name +} + +func (v BaseVillage) Points() int { + return v.points +} + +func (v BaseVillage) X() int { + return v.x +} + +func (v BaseVillage) Y() int { + return v.y +} + +func (v BaseVillage) Continent() string { + return v.continent +} + +func (v BaseVillage) Bonus() int { + return v.bonus +} + +func (v BaseVillage) PlayerID() int { + return v.playerID +} + +func (v BaseVillage) ProfileURL() *url.URL { + return v.profileURL +} + +func (v BaseVillage) IsZero() bool { + return v == BaseVillage{} +} + +type BaseVillages []BaseVillage diff --git a/internal/domain/base_village_test.go b/internal/domain/base_village_test.go new file mode 100644 index 0000000..4c56adb --- /dev/null +++ b/internal/domain/base_village_test.go @@ -0,0 +1,327 @@ +package domain_test + +import ( + "net/url" + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewBaseVillage(t *testing.T) { + t.Parallel() + + validBaseVillage := domaintest.NewBaseVillage(t) + + type args struct { + id int + name string + points int + x int + y int + continent string + bonus int + playerID int + profileURL *url.URL + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + }, + { + name: "ERR: id < 1", + args: args{ + id: 0, + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "id", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + { + name: "ERR: len(name) < 1", + args: args{ + id: validBaseVillage.ID(), + name: "", + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "name", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 150, + Current: 0, + }, + }, + }, + { + name: "ERR: len(name) > 150", + args: args{ + id: validBaseVillage.ID(), + name: gofakeit.LetterN(151), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "name", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 150, + Current: 151, + }, + }, + }, + { + name: "ERR: points < 1", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: 0, + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "points", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + { + name: "ERR: x < 0", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: -1, + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "x", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: y < 0", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: -1, + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "y", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: len(continent) < 1", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: "", + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "continent", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 5, + Current: 0, + }, + }, + }, + { + name: "ERR: len(continent) > 5", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: gofakeit.LetterN(6), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "continent", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 5, + Current: 6, + }, + }, + }, + { + name: "ERR: bonus < 0", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: -1, + playerID: validBaseVillage.PlayerID(), + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "bonus", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: playerID < 0", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: -1, + profileURL: validBaseVillage.ProfileURL(), + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "playerID", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + { + name: "ERR: profileURL can't be nil", + args: args{ + id: validBaseVillage.ID(), + name: validBaseVillage.Name(), + points: validBaseVillage.Points(), + x: validBaseVillage.X(), + y: validBaseVillage.Y(), + continent: validBaseVillage.Continent(), + bonus: validBaseVillage.Bonus(), + playerID: validBaseVillage.PlayerID(), + profileURL: nil, + }, + expectedErr: domain.ValidationError{ + Model: "BaseVillage", + Field: "profileURL", + Err: domain.ErrNil, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := domain.NewBaseVillage( + tt.args.id, + tt.args.name, + tt.args.points, + tt.args.x, + tt.args.y, + tt.args.continent, + tt.args.bonus, + tt.args.playerID, + tt.args.profileURL, + ) + require.ErrorIs(t, err, tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.id, res.ID()) + assert.Equal(t, tt.args.name, res.Name()) + assert.Equal(t, tt.args.points, res.Points()) + assert.Equal(t, tt.args.x, res.X()) + assert.Equal(t, tt.args.y, res.Y()) + assert.Equal(t, tt.args.continent, res.Continent()) + assert.Equal(t, tt.args.bonus, res.Bonus()) + assert.Equal(t, tt.args.playerID, res.PlayerID()) + assert.Equal(t, tt.args.profileURL, res.ProfileURL()) + }) + } +} diff --git a/internal/domain/domaintest/base_village.go b/internal/domain/domaintest/base_village.go new file mode 100644 index 0000000..9b4c7d8 --- /dev/null +++ b/internal/domain/domaintest/base_village.go @@ -0,0 +1,45 @@ +package domaintest + +import ( + "net/url" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" +) + +type BaseVillageConfig struct { + ID int + PlayerID int +} + +func NewBaseVillage(tb TestingTB, opts ...func(cfg *BaseVillageConfig)) domain.BaseVillage { + tb.Helper() + + cfg := &BaseVillageConfig{ + ID: RandID(), + PlayerID: gofakeit.IntRange(0, 10000), + } + + for _, opt := range opts { + opt(cfg) + } + + u, err := url.ParseRequestURI(gofakeit.URL()) + require.NoError(tb, err) + + v, err := domain.NewBaseVillage( + cfg.ID, + gofakeit.LetterN(50), + gofakeit.IntRange(1, 10000), + gofakeit.IntRange(1, 1000), + gofakeit.IntRange(1, 1000), + gofakeit.LetterN(3), + 0, + cfg.PlayerID, + u, + ) + require.NoError(tb, err) + + return v +} diff --git a/internal/domain/domaintest/village.go b/internal/domain/domaintest/village.go new file mode 100644 index 0000000..d3dfeff --- /dev/null +++ b/internal/domain/domaintest/village.go @@ -0,0 +1,46 @@ +package domaintest + +import ( + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" +) + +type VillageConfig struct { + ID int + ServerKey string + PlayerID int +} + +func NewVillage(tb TestingTB, opts ...func(cfg *VillageConfig)) domain.Village { + tb.Helper() + + cfg := &VillageConfig{ + ID: RandID(), + ServerKey: RandServerKey(), + PlayerID: gofakeit.IntRange(0, 10000), + } + + for _, opt := range opts { + opt(cfg) + } + + v, err := domain.UnmarshalVillageFromDatabase( + cfg.ID, + cfg.ServerKey, + gofakeit.LetterN(50), + gofakeit.IntRange(1, 10000), + gofakeit.IntRange(1, 1000), + gofakeit.IntRange(1, 1000), + gofakeit.LetterN(3), + 0, + cfg.PlayerID, + gofakeit.URL(), + time.Now(), + ) + require.NoError(tb, err) + + return v +} diff --git a/internal/domain/player.go b/internal/domain/player.go index 5aa5fe1..7c0cf57 100644 --- a/internal/domain/player.go +++ b/internal/domain/player.go @@ -35,6 +35,8 @@ type Player struct { deletedAt time.Time } +const playerModelName = "Player" + // UnmarshalPlayerFromDatabase unmarshals Player from the database. // // It should be used only for unmarshalling from the database! @@ -67,6 +69,14 @@ func UnmarshalPlayerFromDatabase( } } + if err := validateServerKey(serverKey); err != nil { + return Player{}, ValidationError{ + Model: playerModelName, + Field: "serverKey", + Err: err, + } + } + profileURL, err := parseURL(rawProfileURL) if err != nil { return Player{}, ValidationError{ @@ -98,8 +108,6 @@ func UnmarshalPlayerFromDatabase( }, nil } -const playerModelName = "Player" - func (p Player) ID() int { return p.id } @@ -191,7 +199,7 @@ func (p Player) IsDeleted() bool { type Players []Player -// Delete finds all tribes that are not in the given slice with active tribes and returns their ids. +// Delete finds all players that are not in the given slice with active players and returns their ids. // Both slices must be sorted in ascending order by ID. func (ps Players) Delete(active BasePlayers) []int { //nolint:prealloc @@ -227,7 +235,7 @@ const createPlayerParamsModelName = "CreatePlayerParams" // NewCreatePlayerParams constructs a slice of CreatePlayerParams based on the given parameters. // Both slices must be sorted in ascending order by ID -// + if storedPlayers contains tribes from different servers. they must be sorted in ascending order by server key. +// + if storedPlayers contains players from different servers. they must be sorted in ascending order by server key. // //nolint:gocyclo func NewCreatePlayerParams(serverKey string, players BasePlayers, storedPlayers Players) ([]CreatePlayerParams, error) { @@ -299,40 +307,40 @@ func NewCreatePlayerParams(serverKey string, players BasePlayers, storedPlayers return params, nil } -func (c CreatePlayerParams) Base() BasePlayer { - return c.base +func (params CreatePlayerParams) Base() BasePlayer { + return params.base } -func (c CreatePlayerParams) ServerKey() string { - return c.serverKey +func (params CreatePlayerParams) ServerKey() string { + return params.serverKey } -func (c CreatePlayerParams) BestRank() int { - return c.bestRank +func (params CreatePlayerParams) BestRank() int { + return params.bestRank } -func (c CreatePlayerParams) BestRankAt() time.Time { - return c.bestRankAt +func (params CreatePlayerParams) BestRankAt() time.Time { + return params.bestRankAt } -func (c CreatePlayerParams) MostPoints() int { - return c.mostPoints +func (params CreatePlayerParams) MostPoints() int { + return params.mostPoints } -func (c CreatePlayerParams) MostPointsAt() time.Time { - return c.mostPointsAt +func (params CreatePlayerParams) MostPointsAt() time.Time { + return params.mostPointsAt } -func (c CreatePlayerParams) MostVillages() int { - return c.mostVillages +func (params CreatePlayerParams) MostVillages() int { + return params.mostVillages } -func (c CreatePlayerParams) MostVillagesAt() time.Time { - return c.mostVillagesAt +func (params CreatePlayerParams) MostVillagesAt() time.Time { + return params.mostVillagesAt } -func (c CreatePlayerParams) LastActivityAt() time.Time { - return c.lastActivityAt +func (params CreatePlayerParams) LastActivityAt() time.Time { + return params.lastActivityAt } type PlayerSort uint8 diff --git a/internal/domain/tribe.go b/internal/domain/tribe.go index 21f613d..af98695 100644 --- a/internal/domain/tribe.go +++ b/internal/domain/tribe.go @@ -75,6 +75,14 @@ func UnmarshalTribeFromDatabase( } } + if err := validateServerKey(serverKey); err != nil { + return Tribe{}, ValidationError{ + Model: tribeModelName, + Field: "serverKey", + Err: err, + } + } + profileURL, err := parseURL(rawProfileURL) if err != nil { return Tribe{}, ValidationError{ diff --git a/internal/domain/validation.go b/internal/domain/validation.go index 458f9cb..382d2fd 100644 --- a/internal/domain/validation.go +++ b/internal/domain/validation.go @@ -253,28 +253,12 @@ func validateStringLen(s string, min, max int) error { return nil } -func validateServerKey(key string) error { - if l := len(key); l < serverKeyMinLength || l > serverKeyMaxLength { - return LenOutOfRangeError{ - Min: serverKeyMinLength, - Max: serverKeyMaxLength, - Current: l, - } - } - - return nil +func validateVersionCode(code string) error { + return validateStringLen(code, versionCodeMinLength, versionCodeMaxLength) } -func validateVersionCode(code string) error { - if l := len(code); l < versionCodeMinLength || l > versionCodeMaxLength { - return LenOutOfRangeError{ - Min: versionCodeMinLength, - Max: versionCodeMaxLength, - Current: l, - } - } - - return nil +func validateServerKey(key string) error { + return validateStringLen(key, serverKeyMinLength, serverKeyMaxLength) } func validateIntInRange(current, min, max int) error { diff --git a/internal/domain/village.go b/internal/domain/village.go new file mode 100644 index 0000000..b3de465 --- /dev/null +++ b/internal/domain/village.go @@ -0,0 +1,351 @@ +package domain + +import ( + "cmp" + "fmt" + "math" + "net/url" + "slices" + "time" +) + +const ( + villageNameMinLength = 1 + villageNameMaxLength = 150 + villageContinentMinLength = 1 + villageContinentMaxLength = 5 +) + +type Village struct { + id int + serverKey string + name string + points int + x int + y int + continent string + bonus int + playerID int + profileURL *url.URL + createdAt time.Time +} + +const villageModelName = "Village" + +// UnmarshalVillageFromDatabase unmarshals Village from the database. +// +// It should be used only for unmarshalling from the database! +// You can't use UnmarshalVillageFromDatabase as constructor - It may put domain into the invalid state! +func UnmarshalVillageFromDatabase( + id int, + serverKey string, + name string, + points int, + x int, + y int, + continent string, + bonus int, + playerID int, + rawProfileURL string, + createdAt time.Time, +) (Village, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return Village{}, ValidationError{ + Model: villageModelName, + Field: "id", + Err: err, + } + } + + if err := validateServerKey(serverKey); err != nil { + return Village{}, ValidationError{ + Model: villageModelName, + Field: "serverKey", + Err: err, + } + } + + profileURL, err := parseURL(rawProfileURL) + if err != nil { + return Village{}, ValidationError{ + Model: villageModelName, + Field: "profileURL", + Err: err, + } + } + + return Village{ + id: id, + serverKey: serverKey, + name: name, + points: points, + x: x, + y: y, + continent: continent, + bonus: bonus, + playerID: playerID, + profileURL: profileURL, + createdAt: createdAt, + }, nil +} + +func (v Village) ID() int { + return v.id +} + +func (v Village) ServerKey() string { + return v.serverKey +} + +func (v Village) Name() string { + return v.name +} + +func (v Village) Points() int { + return v.points +} + +func (v Village) X() int { + return v.x +} + +func (v Village) Y() int { + return v.y +} + +func (v Village) Continent() string { + return v.continent +} + +func (v Village) Bonus() int { + return v.bonus +} + +func (v Village) PlayerID() int { + return v.playerID +} + +func (v Village) ProfileURL() *url.URL { + return v.profileURL +} + +func (v Village) CreatedAt() time.Time { + return v.createdAt +} + +func (v Village) Base() BaseVillage { + return BaseVillage{ + id: v.id, + name: v.name, + points: v.points, + x: v.x, + y: v.y, + continent: v.continent, + bonus: v.bonus, + playerID: v.playerID, + profileURL: v.profileURL, + } +} + +type Villages []Village + +// Delete finds all villages that are not in the given slice with active villages and returns their ids. +// Both slices must be sorted in ascending order by ID. +func (vs Villages) Delete(active BaseVillages) []int { + //nolint:prealloc + var toDelete []int + + for _, v := range vs { + _, found := slices.BinarySearchFunc(active, v, func(a BaseVillage, b Village) int { + return cmp.Compare(a.ID(), b.ID()) + }) + if found { + continue + } + + toDelete = append(toDelete, v.ID()) + } + + return toDelete +} + +type CreateVillageParams struct { + base BaseVillage + serverKey string +} + +const createVillageParamsModelName = "CreateVillageParams" + +func NewCreateVillageParams(serverKey string, villages BaseVillages) ([]CreateVillageParams, error) { + if err := validateServerKey(serverKey); err != nil { + return nil, ValidationError{ + Model: createVillageParamsModelName, + Field: "serverKey", + Err: err, + } + } + + params := make([]CreateVillageParams, 0, len(villages)) + + for i, v := range villages { + if v.IsZero() { + return nil, fmt.Errorf("villages[%d] is an empty struct", i) + } + + params = append(params, CreateVillageParams{ + base: v, + serverKey: serverKey, + }) + } + + return params, nil +} + +func (params CreateVillageParams) Base() BaseVillage { + return params.base +} + +func (params CreateVillageParams) ServerKey() string { + return params.serverKey +} + +type VillageSort uint8 + +const ( + VillageSortIDASC VillageSort = iota + 1 + VillageSortIDDESC + VillageSortServerKeyASC + VillageSortServerKeyDESC +) + +const VillageListMaxLimit = 500 + +type ListVillagesParams struct { + ids []int + idGT NullInt + serverKeys []string + sort []VillageSort + limit int + offset int +} + +const listVillagesParamsModelName = "ListVillagesParams" + +func NewListVillagesParams() ListVillagesParams { + return ListVillagesParams{ + sort: []VillageSort{ + VillageSortServerKeyASC, + VillageSortIDASC, + }, + limit: VillageListMaxLimit, + } +} + +func (params *ListVillagesParams) IDs() []int { + return params.ids +} + +func (params *ListVillagesParams) SetIDs(ids []int) error { + for i, id := range ids { + if err := validateIntInRange(id, 0, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listVillagesParamsModelName, + Field: "ids", + Index: i, + Err: err, + } + } + } + + params.ids = ids + + return nil +} + +func (params *ListVillagesParams) IDGT() NullInt { + return params.idGT +} + +func (params *ListVillagesParams) SetIDGT(idGT NullInt) error { + if idGT.Valid { + if err := validateIntInRange(idGT.Value, 0, math.MaxInt); err != nil { + return ValidationError{ + Model: listVillagesParamsModelName, + Field: "idGT", + Err: err, + } + } + } + + params.idGT = idGT + + return nil +} + +func (params *ListVillagesParams) ServerKeys() []string { + return params.serverKeys +} + +func (params *ListVillagesParams) SetServerKeys(serverKeys []string) error { + params.serverKeys = serverKeys + return nil +} + +func (params *ListVillagesParams) Sort() []VillageSort { + return params.sort +} + +const ( + villageSortMinLength = 1 + villageSortMaxLength = 2 +) + +func (params *ListVillagesParams) SetSort(sort []VillageSort) error { + if err := validateSliceLen(sort, villageSortMinLength, villageSortMaxLength); err != nil { + return ValidationError{ + Model: listVillagesParamsModelName, + Field: "sort", + Err: err, + } + } + + params.sort = sort + + return nil +} + +func (params *ListVillagesParams) Limit() int { + return params.limit +} + +func (params *ListVillagesParams) SetLimit(limit int) error { + if err := validateIntInRange(limit, 1, VillageListMaxLimit); err != nil { + return ValidationError{ + Model: listVillagesParamsModelName, + Field: "limit", + Err: err, + } + } + + params.limit = limit + + return nil +} + +func (params *ListVillagesParams) Offset() int { + return params.offset +} + +func (params *ListVillagesParams) SetOffset(offset int) error { + if err := validateIntInRange(offset, 0, math.MaxInt); err != nil { + return ValidationError{ + Model: listVillagesParamsModelName, + Field: "offset", + Err: err, + } + } + + params.offset = offset + + return nil +} diff --git a/internal/domain/village_test.go b/internal/domain/village_test.go new file mode 100644 index 0000000..d9f9262 --- /dev/null +++ b/internal/domain/village_test.go @@ -0,0 +1,390 @@ +package domain_test + +import ( + "cmp" + "fmt" + "math" + "slices" + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVillages_Delete(t *testing.T) { + t.Parallel() + + server := domaintest.NewServer(t) + + active := domain.BaseVillages{ + domaintest.NewBaseVillage(t), + domaintest.NewBaseVillage(t), + domaintest.NewBaseVillage(t), + } + slices.SortFunc(active, func(a, b domain.BaseVillage) int { + return cmp.Compare(a.ID(), b.ID()) + }) + + villages := domain.Villages{ + domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) { + cfg.ID = active[0].ID() + cfg.ServerKey = server.Key() + }), + domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) { + cfg.ServerKey = server.Key() + }), + domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) { + cfg.ID = active[1].ID() + cfg.ServerKey = server.Key() + }), + } + expectedIDs := []int{villages[1].ID()} + slices.SortFunc(villages, func(a, b domain.Village) int { + if res := cmp.Compare(a.ServerKey(), b.ServerKey()); res != 0 { + return res + } + return cmp.Compare(a.ID(), b.ID()) + }) + + assert.Equal(t, expectedIDs, villages.Delete(active)) +} + +func TestNewCreateVillageParams(t *testing.T) { + t.Parallel() + + server := domaintest.NewServer(t) + + villages := domain.BaseVillages{ + domaintest.NewBaseVillage(t), + domaintest.NewBaseVillage(t), + domaintest.NewBaseVillage(t), + } + slices.SortFunc(villages, func(a, b domain.BaseVillage) int { + return cmp.Compare(a.ID(), b.ID()) + }) + + res, err := domain.NewCreateVillageParams(server.Key(), villages) + require.NoError(t, err) + for i, v := range villages { + idx := slices.IndexFunc(res, func(params domain.CreateVillageParams) bool { + return params.Base().ID() == v.ID() && params.ServerKey() == server.Key() + }) + require.GreaterOrEqualf(t, idx, 0, "village[%d] not found", i) + + params := res[idx] + + assert.Equalf(t, v, params.Base(), "villages[%d]", i) + } +} + +func TestListVillagesParams_SetIDs(t *testing.T) { + t.Parallel() + + type args struct { + ids []int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + ids: []int{ + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + }, + }, + }, + { + name: "ERR: value < 0", + args: args{ + ids: []int{ + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + -1, + gofakeit.IntRange(0, math.MaxInt), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListVillagesParams", + Field: "ids", + Index: 3, + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetIDs(tt.args.ids), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.ids, params.IDs()) + }) + } +} + +func TestListVillagesParams_SetIDGT(t *testing.T) { + t.Parallel() + + type args struct { + idGT domain.NullInt + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + idGT: domain.NullInt{ + Value: gofakeit.IntRange(0, math.MaxInt), + Valid: true, + }, + }, + }, + { + name: "ERR: value < 0", + args: args{ + idGT: domain.NullInt{ + Value: -1, + Valid: true, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + Field: "idGT", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetIDGT(tt.args.idGT), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.idGT, params.IDGT()) + }) + } +} + +func TestListVillagesParams_SetSort(t *testing.T) { + t.Parallel() + + type args struct { + sort []domain.VillageSort + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + sort: []domain.VillageSort{ + domain.VillageSortIDASC, + domain.VillageSortServerKeyASC, + }, + }, + }, + { + name: "ERR: len(sort) < 1", + args: args{ + sort: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 2, + Current: 0, + }, + }, + }, + { + name: "ERR: len(sort) > 2", + args: args{ + sort: []domain.VillageSort{ + domain.VillageSortIDASC, + domain.VillageSortServerKeyASC, + domain.VillageSortServerKeyDESC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + 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.NewListVillagesParams() + + require.ErrorIs(t, params.SetSort(tt.args.sort), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.sort, params.Sort()) + }) + } +} + +func TestListVillagesParams_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.VillageListMaxLimit, + }, + }, + { + name: "ERR: limit < 1", + args: args{ + limit: 0, + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + Field: "limit", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + { + name: fmt.Sprintf("ERR: limit > %d", domain.VillageListMaxLimit), + args: args{ + limit: domain.VillageListMaxLimit + 1, + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + Field: "limit", + Err: domain.MaxLessEqualError{ + Max: domain.VillageListMaxLimit, + Current: domain.VillageListMaxLimit + 1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetLimit(tt.args.limit), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.limit, params.Limit()) + }) + } +} + +func TestListVillagesParams_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: "ListVillagesParams", + 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.NewListVillagesParams() + + 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/port/consumer_watermill_village.go b/internal/port/consumer_watermill_village.go new file mode 100644 index 0000000..e476d58 --- /dev/null +++ b/internal/port/consumer_watermill_village.go @@ -0,0 +1,63 @@ +package port + +import ( + "gitea.dwysokinski.me/twhelp/corev3/internal/app" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/watermillmsg" + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill/message" +) + +type VillageWatermillConsumer struct { + svc *app.VillageService + subscriber message.Subscriber + logger watermill.LoggerAdapter + marshaler watermillmsg.Marshaler + eventServerSyncedTopic string +} + +func NewVillageWatermillConsumer( + svc *app.VillageService, + subscriber message.Subscriber, + logger watermill.LoggerAdapter, + marshaler watermillmsg.Marshaler, + eventServerSyncedTopic string, +) *VillageWatermillConsumer { + return &VillageWatermillConsumer{ + svc: svc, + subscriber: subscriber, + logger: logger, + marshaler: marshaler, + eventServerSyncedTopic: eventServerSyncedTopic, + } +} + +func (c *VillageWatermillConsumer) Register(router *message.Router) { + router.AddNoPublisherHandler( + "VillageConsumer.sync", + c.eventServerSyncedTopic, + c.subscriber, + c.sync, + ) +} + +func (c *VillageWatermillConsumer) sync(msg *message.Message) error { + var rawPayload watermillmsg.ServerSyncedEventPayload + + if err := c.marshaler.Unmarshal(msg, &rawPayload); err != nil { + c.logger.Error("couldn't unmarshal payload", err, watermill.LogFields{ + "handler": message.HandlerNameFromCtx(msg.Context()), + }) + return nil + } + + payload, err := domain.NewServerSyncedEventPayload(rawPayload.Key, rawPayload.URL, rawPayload.VersionCode) + if err != nil { + c.logger.Error("couldn't construct domain.ServerSyncedEventPayload", err, watermill.LogFields{ + "handler": message.HandlerNameFromCtx(msg.Context()), + }) + return nil + } + + return c.svc.Sync(msg.Context(), payload) +} diff --git a/k8s/base/kustomization.yml b/k8s/base/kustomization.yml index df64eba..ba8637b 100644 --- a/k8s/base/kustomization.yml +++ b/k8s/base/kustomization.yml @@ -5,6 +5,7 @@ resources: - server-consumer.yml - tribe-consumer.yml - player-consumer.yml + - village-consumer.yml images: - name: twhelp newName: twhelp diff --git a/k8s/base/village-consumer.yml b/k8s/base/village-consumer.yml new file mode 100644 index 0000000..239bc11 --- /dev/null +++ b/k8s/base/village-consumer.yml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: twhelp-village-consumer-deployment +spec: + selector: + matchLabels: + app: twhelp-village-consumer + template: + metadata: + labels: + app: twhelp-village-consumer + spec: + containers: + - name: twhelp-village-consumer + image: twhelp + args: [consumer, village] + env: + - name: APP_MODE + value: development + - name: LOG_LEVEL + value: debug + - name: DB_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: twhelp-secret + key: db-connection-string + - name: RABBITMQ_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: twhelp-secret + key: rabbitmq-connection-string + livenessProbe: + exec: + command: [cat, /tmp/live] + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 100Mi + limits: + cpu: 300m + memory: 300Mi