From ba3b0ea7a6b0608a60c8227be24e7ce1ffb2663b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Tue, 16 Jan 2024 06:28:03 +0000 Subject: [PATCH] feat: player snapshots (#45) Reviewed-on: https://gitea.dwysokinski.me/twhelp/corev3/pulls/45 --- cmd/twhelp/cmd_consumer.go | 27 +- cmd/twhelp/cmd_job.go | 9 +- cmd/twhelp/rabbitmq.go | 36 +- .../adapter/repository_bun_player_snapshot.go | 105 ++++++ .../repository_bun_player_snapshot_test.go | 29 ++ internal/adapter/repository_bun_server.go | 4 + .../repository_player_snapshot_test.go | 335 ++++++++++++++++++ internal/adapter/repository_server_test.go | 10 + internal/adapter/repository_test.go | 39 +- internal/adapter/testdata/fixture.yml | 92 +++++ internal/app/service_player.go | 4 + internal/app/service_player_snapshot.go | 92 +++++ internal/app/service_server.go | 17 + internal/app/service_snapshot.go | 60 +++- internal/bun/bunmodel/player_snapshot.go | 76 ++++ internal/bun/buntest/fixture.go | 1 + internal/domain/player.go | 4 + internal/domain/player_snapshot.go | 289 +++++++++++++++ internal/domain/player_snapshot_test.go | 236 ++++++++++++ internal/domain/server.go | 41 ++- internal/domain/tribe_snapshot.go | 8 + internal/port/consumer_watermill_player.go | 58 ++- internal/port/consumer_watermill_server.go | 77 ++-- internal/service/data_sync_test.go | 11 +- internal/service/ennoblement_sync_test.go | 1 + 25 files changed, 1570 insertions(+), 91 deletions(-) create mode 100644 internal/adapter/repository_bun_player_snapshot.go create mode 100644 internal/adapter/repository_bun_player_snapshot_test.go create mode 100644 internal/adapter/repository_player_snapshot_test.go create mode 100644 internal/app/service_player_snapshot.go create mode 100644 internal/bun/bunmodel/player_snapshot.go create mode 100644 internal/domain/player_snapshot.go create mode 100644 internal/domain/player_snapshot_test.go diff --git a/cmd/twhelp/cmd_consumer.go b/cmd/twhelp/cmd_consumer.go index 0e789dc..644266f 100644 --- a/cmd/twhelp/cmd_consumer.go +++ b/cmd/twhelp/cmd_consumer.go @@ -66,6 +66,7 @@ var cmdConsumer = &cli.Command{ c.String(rmqFlagTopicVillagesSyncedEvent.Name), c.String(rmqFlagTopicEnnoblementsSyncedEvent.Name), c.String(rmqFlagTopicTribeSnapshotsCreatedEvent.Name), + c.String(rmqFlagTopicPlayerSnapshotsCreatedEvent.Name), ) consumer.Register(router) @@ -153,6 +154,12 @@ var cmdConsumer = &cli.Command{ marshaler, c.String(rmqFlagTopicPlayersSyncedEvent.Name), ) + playerSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( + publisher, + newWatermillMarshaler(), + c.String(rmqFlagTopicCreatePlayerSnapshotsCmd.Name), + c.String(rmqFlagTopicPlayerSnapshotsCreatedEvent.Name), + ) twSvc, err := newTWServiceFromFlags(c) if err != nil { @@ -160,18 +167,26 @@ var cmdConsumer = &cli.Command{ } tribeChangeSvc := app.NewTribeChangeService(adapter.NewTribeChangeBunRepository(db)) + playerSvc := app.NewPlayerService( + adapter.NewPlayerBunRepository(db), + tribeChangeSvc, + twSvc, + playerPublisher, + ) + playerSnapshotSvc := app.NewPlayerSnapshotService( + adapter.NewPlayerSnapshotBunRepository(db), + playerSvc, + playerSnapshotPublisher, + ) consumer := port.NewPlayerWatermillConsumer( - app.NewPlayerService( - adapter.NewPlayerBunRepository(db), - tribeChangeSvc, - twSvc, - playerPublisher, - ), + playerSvc, + playerSnapshotSvc, subscriber, logger, marshaler, c.String(rmqFlagTopicServerSyncedEvent.Name), + c.String(rmqFlagTopicCreatePlayerSnapshotsCmd.Name), ) consumer.Register(router) diff --git a/cmd/twhelp/cmd_job.go b/cmd/twhelp/cmd_job.go index 0256d0f..c2e07c5 100644 --- a/cmd/twhelp/cmd_job.go +++ b/cmd/twhelp/cmd_job.go @@ -198,9 +198,16 @@ var ( c.String(rmqFlagTopicTribeSnapshotsCreatedEvent.Name), ) + playerSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( + publisher, + newWatermillMarshaler(), + c.String(rmqFlagTopicCreatePlayerSnapshotsCmd.Name), + c.String(rmqFlagTopicPlayerSnapshotsCreatedEvent.Name), + ) + versionSvc := app.NewVersionService(adapter.NewVersionBunRepository(bunDB)) serverSvc := app.NewServerService(adapter.NewServerBunRepository(bunDB), nil, nil) - snapshotSvc := app.NewSnapshotService(versionSvc, serverSvc, tribeSnapshotPublisher) + snapshotSvc := app.NewSnapshotService(versionSvc, serverSvc, tribeSnapshotPublisher, playerSnapshotPublisher) shutdownSignalCtx, stop := newShutdownSignalContext(c.Context) defer stop() diff --git a/cmd/twhelp/rabbitmq.go b/cmd/twhelp/rabbitmq.go index 47482cb..ee86194 100644 --- a/cmd/twhelp/rabbitmq.go +++ b/cmd/twhelp/rabbitmq.go @@ -31,11 +31,31 @@ var ( Value: "tribes.event.synced", EnvVars: []string{"RABBITMQ_TOPIC_TRIBES_SYNCED_EVENT"}, } + rmqFlagTopicCreateTribeSnapshotsCmd = &cli.StringFlag{ + Name: "rabbitmq.topic.createTribeSnapshotsCmd", + Value: "tribes.cmd.create_snapshots", + EnvVars: []string{"RABBITMQ_TOPIC_CREATE_TRIBE_SNAPSHOTS_CMD"}, + } + rmqFlagTopicTribeSnapshotsCreatedEvent = &cli.StringFlag{ + Name: "rabbitmq.topic.tribeSnapshotsCreatedEvent", + Value: "tribes.event.snapshots_created", + EnvVars: []string{"RABBITMQ_TOPIC_TRIBE_SNAPSHOTS_CREATED_EVENT"}, + } rmqFlagTopicPlayersSyncedEvent = &cli.StringFlag{ Name: "rabbitmq.topic.playersSyncedEvent", Value: "players.event.synced", EnvVars: []string{"RABBITMQ_TOPIC_PLAYERS_SYNCED_EVENT"}, } + rmqFlagTopicCreatePlayerSnapshotsCmd = &cli.StringFlag{ + Name: "rabbitmq.topic.createPlayerSnapshotsCmd", + Value: "players.cmd.create_snapshots", + EnvVars: []string{"RABBITMQ_TOPIC_CREATE_PLAYER_SNAPSHOTS_CMD"}, + } + rmqFlagTopicPlayerSnapshotsCreatedEvent = &cli.StringFlag{ + Name: "rabbitmq.topic.playerSnapshotsCreatedEvent", + Value: "players.event.snapshots_created", + EnvVars: []string{"RABBITMQ_TOPIC_PLAYER_SNAPSHOTS_CREATED_EVENT"}, + } rmqFlagTopicVillagesSyncedEvent = &cli.StringFlag{ Name: "rabbitmq.topic.villagesSyncedEvent", Value: "villages.event.synced", @@ -51,27 +71,19 @@ var ( Value: "ennoblements.event.synced", EnvVars: []string{"RABBITMQ_TOPIC_ENNOBLEMENTS_SYNCED_EVENT"}, } - rmqFlagTopicCreateTribeSnapshotsCmd = &cli.StringFlag{ - Name: "rabbitmq.topic.createTribeSnapshotsCmd", - Value: "tribes.cmd.create_snapshots", - EnvVars: []string{"RABBITMQ_TOPIC_CREATE_TRIBE_SNAPSHOTS_CMD"}, - } - rmqFlagTopicTribeSnapshotsCreatedEvent = &cli.StringFlag{ - Name: "rabbitmq.topic.tribeSnapshotsCreatedEvent", - Value: "tribes.event.snapshots_created", - EnvVars: []string{"RABBITMQ_TOPIC_TRIBE_SNAPSHOTS_CREATED_EVENT"}, - } rmqFlags = []cli.Flag{ rmqFlagConnectionString, rmqFlagTopicSyncServersCmd, rmqFlagTopicServerSyncedEvent, rmqFlagTopicTribesSyncedEvent, + rmqFlagTopicCreateTribeSnapshotsCmd, + rmqFlagTopicTribeSnapshotsCreatedEvent, rmqFlagTopicPlayersSyncedEvent, + rmqFlagTopicCreatePlayerSnapshotsCmd, + rmqFlagTopicPlayerSnapshotsCreatedEvent, rmqFlagTopicVillagesSyncedEvent, rmqFlagTopicSyncEnnoblementsCmd, rmqFlagTopicEnnoblementsSyncedEvent, - rmqFlagTopicCreateTribeSnapshotsCmd, - rmqFlagTopicTribeSnapshotsCreatedEvent, } ) diff --git a/internal/adapter/repository_bun_player_snapshot.go b/internal/adapter/repository_bun_player_snapshot.go new file mode 100644 index 0000000..a331977 --- /dev/null +++ b/internal/adapter/repository_bun_player_snapshot.go @@ -0,0 +1,105 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/bun/bunmodel" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/uptrace/bun" +) + +type PlayerSnapshotBunRepository struct { + db bun.IDB +} + +func NewPlayerSnapshotBunRepository(db bun.IDB) *PlayerSnapshotBunRepository { + return &PlayerSnapshotBunRepository{db: db} +} + +func (repo *PlayerSnapshotBunRepository) Create( + ctx context.Context, + params ...domain.CreatePlayerSnapshotParams, +) error { + if len(params) == 0 { + return nil + } + + now := time.Now() + playerSnapshots := make(bunmodel.PlayerSnapshots, 0, len(params)) + + for _, p := range params { + playerSnapshots = append(playerSnapshots, bunmodel.PlayerSnapshot{ + PlayerID: p.PlayerID(), + NumVillages: p.NumVillages(), + Points: p.Points(), + Rank: p.Rank(), + TribeID: p.TribeID(), + ServerKey: p.ServerKey(), + Date: p.Date(), + CreatedAt: now, + OpponentsDefeated: bunmodel.NewOpponentsDefeated(p.OD()), + }) + } + + if _, err := repo.db.NewInsert(). + Model(&playerSnapshots). + Ignore(). + Returning(""). + Exec(ctx); err != nil { + return fmt.Errorf("something went wrong while inserting player snapshots into the db: %w", err) + } + + return nil +} + +func (repo *PlayerSnapshotBunRepository) List( + ctx context.Context, + params domain.ListPlayerSnapshotsParams, +) (domain.PlayerSnapshots, error) { + var playerSnapshots bunmodel.PlayerSnapshots + + if err := repo.db.NewSelect(). + Model(&playerSnapshots). + Apply(listPlayerSnapshotsParamsApplier{params: params}.apply). + Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("couldn't select player snapshots from the db: %w", err) + } + + return playerSnapshots.ToDomain() +} + +type listPlayerSnapshotsParamsApplier struct { + params domain.ListPlayerSnapshotsParams +} + +//nolint:gocyclo +func (a listPlayerSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { + if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 { + q = q.Where("ps.server_key IN (?)", bun.In(serverKeys)) + } + + for _, s := range a.params.Sort() { + switch s { + case domain.PlayerSnapshotSortDateASC: + q = q.Order("ps.date ASC") + case domain.PlayerSnapshotSortDateDESC: + q = q.Order("ps.date DESC") + case domain.PlayerSnapshotSortIDASC: + q = q.Order("ps.id ASC") + case domain.PlayerSnapshotSortIDDESC: + q = q.Order("ps.id DESC") + case domain.PlayerSnapshotSortServerKeyASC: + q = q.Order("ps.server_key ASC") + case domain.PlayerSnapshotSortServerKeyDESC: + q = q.Order("ps.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_player_snapshot_test.go b/internal/adapter/repository_bun_player_snapshot_test.go new file mode 100644 index 0000000..838f654 --- /dev/null +++ b/internal/adapter/repository_bun_player_snapshot_test.go @@ -0,0 +1,29 @@ +package adapter_test + +import ( + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/bun/buntest" +) + +func TestPlayerSnapshotBunRepository_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long-running test") + } + + testPlayerSnapshotRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, postgres.NewDB(t)) + }) +} + +func TestPlayerSnapshotBunRepository_SQLite(t *testing.T) { + t.Parallel() + + testPlayerSnapshotRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, buntest.NewSQLiteDB(t)) + }) +} diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index 6626ab9..68761a6 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -167,6 +167,10 @@ func (a updateServerParamsApplier) apply(q *bun.UpdateQuery) *bun.UpdateQuery { q = q.Set("tribe_snapshots_created_at = ?", tribeSnapshotsCreatedAt.Value) } + if playerSnapshotsCreatedAt := a.params.PlayerSnapshotsCreatedAt(); playerSnapshotsCreatedAt.Valid { + q = q.Set("player_snapshots_created_at = ?", playerSnapshotsCreatedAt.Value) + } + return q } diff --git a/internal/adapter/repository_player_snapshot_test.go b/internal/adapter/repository_player_snapshot_test.go new file mode 100644 index 0000000..231c0bf --- /dev/null +++ b/internal/adapter/repository_player_snapshot_test.go @@ -0,0 +1,335 @@ +package adapter_test + +import ( + "cmp" + "context" + "fmt" + "slices" + "testing" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testPlayerSnapshotRepository(t *testing.T, newRepos func(t *testing.T) repositories) { + t.Helper() + + ctx := context.Background() + + t.Run("Create", func(t *testing.T) { + t.Parallel() + + const dateFormat = "2006-01-02" + + repos := newRepos(t) + + assertCreated := func(t *testing.T, params []domain.CreatePlayerSnapshotParams) { + t.Helper() + + require.NotEmpty(t, params) + + playerSnapshots, err := repos.playerSnapshot.List(ctx, domain.NewListPlayerSnapshotsParams()) + require.NoError(t, err) + for i, p := range params { + date := p.Date().Format(dateFormat) + + idx := slices.IndexFunc(playerSnapshots, func(ps domain.PlayerSnapshot) bool { + return ps.ServerKey() == p.ServerKey() && + ps.PlayerID() == p.PlayerID() && + ps.Date().Format(dateFormat) == date + }) + require.GreaterOrEqualf(t, idx, 0, "params[%d] not found", i) + playerSnapshot := playerSnapshots[idx] + + assert.Equalf(t, p.PlayerID(), playerSnapshot.PlayerID(), "params[%d]", i) + assert.Equalf(t, p.ServerKey(), playerSnapshot.ServerKey(), "params[%d]", i) + assert.Equalf(t, p.NumVillages(), playerSnapshot.NumVillages(), "params[%d]", i) + assert.Equalf(t, p.Points(), playerSnapshot.Points(), "params[%d]", i) + assert.Equalf(t, p.Rank(), playerSnapshot.Rank(), "params[%d]", i) + assert.Equalf(t, p.TribeID(), playerSnapshot.TribeID(), "params[%d]", i) + assert.Equalf(t, p.OD(), playerSnapshot.OD(), "params[%d]", i) + assert.Equalf(t, date, playerSnapshot.Date().Format(dateFormat), "params[%d]", i) + assert.WithinDurationf(t, time.Now(), playerSnapshot.CreatedAt(), time.Minute, "params[%d]", i) + } + } + + assertNoDuplicates := func(t *testing.T, params []domain.CreatePlayerSnapshotParams) { + t.Helper() + + require.NotEmpty(t, params) + + playerSnapshots, err := repos.playerSnapshot.List(ctx, domain.NewListPlayerSnapshotsParams()) + require.NoError(t, err) + + res := make(map[string][]int) + + for _, p := range params { + key := fmt.Sprintf("%s-%d-%s", p.ServerKey(), p.PlayerID(), p.Date().Format(dateFormat)) + + for i, ps := range playerSnapshots { + if ps.ServerKey() == p.ServerKey() && ps.PlayerID() == p.PlayerID() && ps.Date().Equal(p.Date()) { + 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() + + listPlayersParams := domain.NewListPlayersParams() + require.NoError(t, listPlayersParams.SetDeleted(domain.NullBool{ + Value: false, + Valid: true, + })) + require.NoError(t, listPlayersParams.SetLimit(domain.PlayerSnapshotListMaxLimit/2)) + + players, err := repos.player.List(ctx, listPlayersParams) + require.NoError(t, err) + require.NotEmpty(t, players) + + date := time.Now() + + createParams, err := domain.NewCreatePlayerSnapshotParams(players, date) + require.NoError(t, err) + + require.NoError(t, repos.playerSnapshot.Create(ctx, createParams...)) + assertCreated(t, createParams) + + require.NoError(t, repos.playerSnapshot.Create(ctx, createParams...)) + assertNoDuplicates(t, createParams) + }) + + t.Run("OK: len(params) == 0", func(t *testing.T) { + t.Parallel() + + require.NoError(t, repos.playerSnapshot.Create(ctx)) + }) + }) + + t.Run("List & ListCount", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + playerSnapshots, listPlayerSnapshotsErr := repos.playerSnapshot.List(ctx, domain.NewListPlayerSnapshotsParams()) + require.NoError(t, listPlayerSnapshotsErr) + require.NotEmpty(t, playerSnapshots) + randPlayerSnapshot := playerSnapshots[0] + + tests := []struct { + name string + params func(t *testing.T) domain.ListPlayerSnapshotsParams + assertPlayerSnapshots func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) + assertError func(t *testing.T, err error) + assertTotal func(t *testing.T, params domain.ListPlayerSnapshotsParams, total int) + }{ + { + name: "OK: default params", + params: func(t *testing.T) domain.ListPlayerSnapshotsParams { + t.Helper() + return domain.NewListPlayerSnapshotsParams() + }, + assertPlayerSnapshots: func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) { + t.Helper() + assert.NotEmpty(t, len(playerSnapshots)) + assert.True(t, slices.IsSortedFunc(playerSnapshots, func(a, b domain.PlayerSnapshot) int { + if x := cmp.Compare(a.ServerKey(), b.ServerKey()); x != 0 { + return x + } + if x := a.Date().Compare(b.Date()); 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.ListPlayerSnapshotsParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: sort=[serverKey DESC, date DESC]", + params: func(t *testing.T) domain.ListPlayerSnapshotsParams { + t.Helper() + params := domain.NewListPlayerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.PlayerSnapshotSort{ + domain.PlayerSnapshotSortServerKeyDESC, + domain.PlayerSnapshotSortDateDESC, + })) + return params + }, + assertPlayerSnapshots: func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) { + t.Helper() + assert.NotEmpty(t, len(playerSnapshots)) + assert.True(t, slices.IsSortedFunc(playerSnapshots, func(a, b domain.PlayerSnapshot) int { + if x := cmp.Compare(a.ServerKey(), b.ServerKey()) * -1; x != 0 { + return x + } + return a.Date().Compare(b.Date()) * -1 + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListPlayerSnapshotsParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: sort=[id ASC]", + params: func(t *testing.T) domain.ListPlayerSnapshotsParams { + t.Helper() + params := domain.NewListPlayerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.PlayerSnapshotSort{ + domain.PlayerSnapshotSortIDASC, + })) + return params + }, + assertPlayerSnapshots: func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) { + t.Helper() + assert.NotEmpty(t, len(playerSnapshots)) + assert.True(t, slices.IsSortedFunc(playerSnapshots, func(a, b domain.PlayerSnapshot) int { + 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.ListPlayerSnapshotsParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: sort=[id DESC]", + params: func(t *testing.T) domain.ListPlayerSnapshotsParams { + t.Helper() + params := domain.NewListPlayerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.PlayerSnapshotSort{ + domain.PlayerSnapshotSortIDDESC, + })) + return params + }, + assertPlayerSnapshots: func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) { + t.Helper() + assert.NotEmpty(t, len(playerSnapshots)) + assert.True(t, slices.IsSortedFunc(playerSnapshots, func(a, b domain.PlayerSnapshot) int { + 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.ListPlayerSnapshotsParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: fmt.Sprintf("OK: serverKeys=[%s]", randPlayerSnapshot.ServerKey()), + params: func(t *testing.T) domain.ListPlayerSnapshotsParams { + t.Helper() + params := domain.NewListPlayerSnapshotsParams() + require.NoError(t, params.SetServerKeys([]string{randPlayerSnapshot.ServerKey()})) + return params + }, + assertPlayerSnapshots: func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) { + t.Helper() + + serverKeys := params.ServerKeys() + + for _, ps := range playerSnapshots { + assert.True(t, slices.Contains(serverKeys, ps.ServerKey())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListPlayerSnapshotsParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: offset=1 limit=2", + params: func(t *testing.T) domain.ListPlayerSnapshotsParams { + t.Helper() + params := domain.NewListPlayerSnapshotsParams() + require.NoError(t, params.SetOffset(1)) + require.NoError(t, params.SetLimit(2)) + return params + }, + assertPlayerSnapshots: func( + t *testing.T, + params domain.ListPlayerSnapshotsParams, + playerSnapshots domain.PlayerSnapshots, + ) { + t.Helper() + assert.Len(t, playerSnapshots, params.Limit()) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListPlayerSnapshotsParams, 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.playerSnapshot.List(ctx, params) + tt.assertError(t, err) + tt.assertPlayerSnapshots(t, params, res) + }) + } + }) +} diff --git a/internal/adapter/repository_server_test.go b/internal/adapter/repository_server_test.go index 2b11271..f57a4c7 100644 --- a/internal/adapter/repository_server_test.go +++ b/internal/adapter/repository_server_test.go @@ -512,6 +512,10 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories Value: time.Now(), Valid: true, })) + require.NoError(t, updateParams.SetPlayerSnapshotsCreatedAt(domain.NullTime{ + Value: time.Now(), + Valid: true, + })) require.NoError(t, repos.server.Update(ctx, serversBeforeUpdate[0].Key(), updateParams)) @@ -561,6 +565,12 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories serversAfterUpdate[0].TribeSnapshotsCreatedAt(), time.Minute, ) + assert.WithinDuration( + t, + updateParams.PlayerSnapshotsCreatedAt().Value, + serversAfterUpdate[0].PlayerSnapshotsCreatedAt(), + time.Minute, + ) }) t.Run("ERR: not found", func(t *testing.T) { diff --git a/internal/adapter/repository_test.go b/internal/adapter/repository_test.go index f498b3d..8849fe3 100644 --- a/internal/adapter/repository_test.go +++ b/internal/adapter/repository_test.go @@ -59,15 +59,21 @@ type tribeSnapshotRepository interface { List(ctx context.Context, params domain.ListTribeSnapshotsParams) (domain.TribeSnapshots, error) } +type playerSnapshotRepository interface { + Create(ctx context.Context, params ...domain.CreatePlayerSnapshotParams) error + List(ctx context.Context, params domain.ListPlayerSnapshotsParams) (domain.PlayerSnapshots, error) +} + type repositories struct { - version versionRepository - server serverRepository - tribe tribeRepository - player playerRepository - village villageRepository - ennoblement ennoblementRepository - tribeChange tribeChangeRepository - tribeSnapshot tribeSnapshotRepository + version versionRepository + server serverRepository + tribe tribeRepository + player playerRepository + village villageRepository + ennoblement ennoblementRepository + tribeChange tribeChangeRepository + tribeSnapshot tribeSnapshotRepository + playerSnapshot playerSnapshotRepository } func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { @@ -76,13 +82,14 @@ func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { buntest.NewFixture(bunDB).Load(tb, context.Background(), os.DirFS("testdata"), "fixture.yml") return repositories{ - version: adapter.NewVersionBunRepository(bunDB), - server: adapter.NewServerBunRepository(bunDB), - tribe: adapter.NewTribeBunRepository(bunDB), - player: adapter.NewPlayerBunRepository(bunDB), - village: adapter.NewVillageBunRepository(bunDB), - ennoblement: adapter.NewEnnoblementBunRepository(bunDB), - tribeChange: adapter.NewTribeChangeBunRepository(bunDB), - tribeSnapshot: adapter.NewTribeSnapshotBunRepository(bunDB), + version: adapter.NewVersionBunRepository(bunDB), + server: adapter.NewServerBunRepository(bunDB), + tribe: adapter.NewTribeBunRepository(bunDB), + player: adapter.NewPlayerBunRepository(bunDB), + village: adapter.NewVillageBunRepository(bunDB), + ennoblement: adapter.NewEnnoblementBunRepository(bunDB), + tribeChange: adapter.NewTribeChangeBunRepository(bunDB), + tribeSnapshot: adapter.NewTribeSnapshotBunRepository(bunDB), + playerSnapshot: adapter.NewPlayerSnapshotBunRepository(bunDB), } } diff --git a/internal/adapter/testdata/fixture.yml b/internal/adapter/testdata/fixture.yml index 7a34b6f..69eb532 100644 --- a/internal/adapter/testdata/fixture.yml +++ b/internal/adapter/testdata/fixture.yml @@ -7496,3 +7496,95 @@ dominance: 54.27478896611433 date: 2020-06-23T00:00:00.000Z created_at: 2020-06-23T13:45:46.000Z +- model: PlayerSnapshot + rows: + - rank_att: 5 + score_att: 38213157 + rank_def: 2 + score_def: 51137731 + rank_sup: 104 + score_sup: 2282683 + rank_total: 1 + score_total: 91633571 + _id: pl169-chcesz-remont-2021-09-02 + id: 100000 + player_id: 6180190 + server_key: pl169 + num_villages: 665 + points: 7298055 + rank: 1 + tribe_id: 27 + date: 2021-09-02T00:00:00.000Z + created_at: 2021-09-02T21:01:03.000Z + - rank_att: 5 + score_att: 38213157 + rank_def: 2 + score_def: 51137731 + rank_sup: 104 + score_sup: 2282683 + rank_total: 1 + score_total: 91633571 + _id: pl169-chcesz-remont-2021-09-03 + id: 100001 + player_id: 6180190 + server_key: pl169 + num_villages: 665 + points: 7298055 + rank: 1 + tribe_id: 27 + date: 2021-09-03T00:00:00.000Z + created_at: 2021-09-03T21:01:03.000Z + - rank_att: 13 + score_att: 23124653 + rank_def: 112 + score_def: 5547007 + rank_sup: 10 + score_sup: 10651344 + rank_total: 23 + score_total: 39323004 + _id: pl169-maddov-2021-09-08 + id: 100050 + player_id: 8419570 + server_key: pl169 + num_villages: 643 + points: 6292398 + rank: 2 + tribe_id: 2 + date: 2021-09-08T00:00:00.000Z + created_at: 2021-09-08T20:01:11.000Z + - rank_att: 10 + score_att: 27638462 + rank_def: 35 + score_def: 5555868 + rank_sup: 3 + score_sup: 9815277 + rank_total: 16 + score_total: 43009607 + _id: de188-1scoo-2021-06-25 + id: 100100 + player_id: 1577279214 + server_key: de188 + num_villages: 878 + points: 9199775 + rank: 1 + tribe_id: 772 + date: 2021-06-25T00:00:00.000Z + created_at: 2021-06-25T11:00:53.000Z + - rank_att: 10 + score_att: 27638462 + rank_def: 35 + score_def: 5555868 + rank_sup: 3 + score_sup: 9815277 + rank_total: 16 + score_total: 43009607 + _id: de188-1scoo-2021-06-26 + id: 100101 + player_id: 1577279214 + server_key: de188 + num_villages: 878 + points: 9199775 + rank: 1 + tribe_id: 772 + date: 2021-06-26T00:00:00.000Z + created_at: 2021-06-26T11:00:53.000Z diff --git a/internal/app/service_player.go b/internal/app/service_player.go index ff1246c..2474581 100644 --- a/internal/app/service_player.go +++ b/internal/app/service_player.go @@ -180,3 +180,7 @@ func (svc *PlayerService) delete(ctx context.Context, serverKey string, players return svc.tribeChangeSvc.Create(ctx, tribeChangesParams...) } + +func (svc *PlayerService) List(ctx context.Context, params domain.ListPlayersParams) (domain.Players, error) { + return svc.repo.List(ctx, params) +} diff --git a/internal/app/service_player_snapshot.go b/internal/app/service_player_snapshot.go new file mode 100644 index 0000000..13c1dff --- /dev/null +++ b/internal/app/service_player_snapshot.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "fmt" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" +) + +type PlayerSnapshotRepository interface { + // Create persists player snapshots in a store (e.g. Postgres). + // Duplicates are ignored. + Create(ctx context.Context, params ...domain.CreatePlayerSnapshotParams) error +} + +type PlayerSnapshotService struct { + repo PlayerSnapshotRepository + playerSvc *PlayerService + pub SnapshotPublisher +} + +func NewPlayerSnapshotService( + repo PlayerSnapshotRepository, + playerSvc *PlayerService, + pub SnapshotPublisher, +) *PlayerSnapshotService { + return &PlayerSnapshotService{repo: repo, playerSvc: playerSvc, pub: pub} +} + +//nolint:gocyclo +func (svc *PlayerSnapshotService) Create( + ctx context.Context, + createSnapshotsCmdPayload domain.CreateSnapshotsCmdPayload, +) error { + serverKey := createSnapshotsCmdPayload.ServerKey() + date := createSnapshotsCmdPayload.Date() + + listPlayersParams := domain.NewListPlayersParams() + if err := listPlayersParams.SetServerKeys([]string{serverKey}); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + if err := listPlayersParams.SetDeleted(domain.NullBool{ + Value: false, + Valid: true, + }); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + if err := listPlayersParams.SetSort([]domain.PlayerSort{domain.PlayerSortIDASC}); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + if err := listPlayersParams.SetLimit(domain.PlayerListMaxLimit); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + for { + players, err := svc.playerSvc.List(ctx, listPlayersParams) + if err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + if len(players) == 0 { + break + } + + params, err := domain.NewCreatePlayerSnapshotParams(players, date) + if err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + if err = svc.repo.Create(ctx, params...); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + if err = listPlayersParams.SetIDGT(domain.NullInt{ + Value: players[len(players)-1].ID(), + Valid: true, + }); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + } + + payload, err := domain.NewSnapshotsCreatedEventPayload(serverKey, createSnapshotsCmdPayload.VersionCode()) + if err != nil { + return fmt.Errorf("%s: couldn't construct domain.SnapshotsCreatedEventPayload: %w", serverKey, err) + } + + if err = svc.pub.EventCreated(ctx, payload); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + return nil +} diff --git a/internal/app/service_server.go b/internal/app/service_server.go index e2dbcd8..1b9ed9c 100644 --- a/internal/app/service_server.go +++ b/internal/app/service_server.go @@ -289,3 +289,20 @@ func (svc *ServerService) UpdateTribeSnapshotsCreatedAt( return svc.repo.Update(ctx, key, updateParams) } + +func (svc *ServerService) UpdatePlayerSnapshotsCreatedAt( + ctx context.Context, + payload domain.SnapshotsCreatedEventPayload, +) error { + key := payload.ServerKey() + + var updateParams domain.UpdateServerParams + if err := updateParams.SetPlayerSnapshotsCreatedAt(domain.NullTime{ + Value: time.Now(), + Valid: true, + }); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + + return svc.repo.Update(ctx, key, updateParams) +} diff --git a/internal/app/service_snapshot.go b/internal/app/service_snapshot.go index 1897cc9..bee5fd7 100644 --- a/internal/app/service_snapshot.go +++ b/internal/app/service_snapshot.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "fmt" "time" @@ -9,20 +10,23 @@ import ( ) type SnapshotService struct { - versionSvc *VersionService - serverSvc *ServerService - tribeSnapshotPublisher SnapshotPublisher + versionSvc *VersionService + serverSvc *ServerService + tribeSnapshotPublisher SnapshotPublisher + playerSnapshotPublisher SnapshotPublisher } func NewSnapshotService( versionSvc *VersionService, serverSvc *ServerService, tribeSnapshotPublisher SnapshotPublisher, + playerSnapshotPublisher SnapshotPublisher, ) *SnapshotService { return &SnapshotService{ - versionSvc: versionSvc, - serverSvc: serverSvc, - tribeSnapshotPublisher: tribeSnapshotPublisher, + versionSvc: versionSvc, + serverSvc: serverSvc, + tribeSnapshotPublisher: tribeSnapshotPublisher, + playerSnapshotPublisher: playerSnapshotPublisher, } } @@ -42,7 +46,11 @@ func (svc *SnapshotService) Create(ctx context.Context) error { snapshotsCreatedAtLT := time.Date(year, month, day, 0, 0, 0, 0, loc) date := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) - if loopErr = svc.createTribe(ctx, v, snapshotsCreatedAtLT, date); loopErr != nil { + loopErr = errors.Join( + svc.publishTribe(ctx, v, snapshotsCreatedAtLT, date), + svc.publishPlayer(ctx, v, snapshotsCreatedAtLT, date), + ) + if loopErr != nil { return loopErr } } @@ -50,7 +58,7 @@ func (svc *SnapshotService) Create(ctx context.Context) error { return nil } -func (svc *SnapshotService) createTribe( +func (svc *SnapshotService) publishTribe( ctx context.Context, v domain.Version, snapshotsCreatedAtLT time.Time, @@ -86,6 +94,42 @@ func (svc *SnapshotService) createTribe( return svc.tribeSnapshotPublisher.CmdCreate(ctx, payloads...) } +func (svc *SnapshotService) publishPlayer( + ctx context.Context, + v domain.Version, + snapshotsCreatedAtLT time.Time, + date time.Time, +) error { + params := domain.NewListServersParams() + if err := params.SetVersionCodes([]string{v.Code()}); err != nil { + return err + } + if err := params.SetOpen(domain.NullBool{ + Value: true, + Valid: true, + }); err != nil { + return err + } + if err := params.SetPlayerSnapshotsCreatedAtLT(domain.NullTime{ + Value: snapshotsCreatedAtLT, + Valid: true, + }); err != nil { + return err + } + + servers, err := svc.serverSvc.ListAll(ctx, params) + if err != nil { + return err + } + + payloads, err := svc.toPayload(v, servers, date) + if err != nil { + return err + } + + return svc.playerSnapshotPublisher.CmdCreate(ctx, payloads...) +} + func (svc *SnapshotService) toPayload( v domain.Version, servers domain.Servers, diff --git a/internal/bun/bunmodel/player_snapshot.go b/internal/bun/bunmodel/player_snapshot.go new file mode 100644 index 0000000..1cfd20a --- /dev/null +++ b/internal/bun/bunmodel/player_snapshot.go @@ -0,0 +1,76 @@ +package bunmodel + +import ( + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/uptrace/bun" +) + +type PlayerSnapshot struct { + bun.BaseModel `bun:"table:player_snapshots,alias:ps"` + + ID int `bun:"id,pk,autoincrement,identity"` + PlayerID int `bun:"player_id,nullzero"` + NumVillages int `bun:"num_villages"` + Points int `bun:"points"` + Rank int `bun:"rank"` + TribeID int `bun:"tribe_id,nullzero"` + Tribe Tribe `bun:"tribe,rel:belongs-to,join:tribe_id=id,join:server_key=server_key"` + ServerKey string `bun:"server_key,nullzero"` + Date time.Time `bun:"date,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero"` + + OpponentsDefeated +} + +func (ps PlayerSnapshot) ToDomain() (domain.PlayerSnapshot, error) { + od, err := ps.OpponentsDefeated.ToDomain() + if err != nil { + return domain.PlayerSnapshot{}, fmt.Errorf( + "couldn't construct domain.PlayerSnapshot (id=%d): %w", + ps.ID, + err, + ) + } + + converted, err := domain.UnmarshalPlayerSnapshotFromDatabase( + ps.ID, + ps.PlayerID, + ps.ServerKey, + ps.NumVillages, + ps.Points, + ps.Rank, + ps.TribeID, + od, + ps.Date, + ps.CreatedAt, + ) + if err != nil { + return domain.PlayerSnapshot{}, fmt.Errorf( + "couldn't construct domain.PlayerSnapshot (id=%d): %w", + ps.ID, + err, + ) + } + + return converted, nil +} + +type PlayerSnapshots []PlayerSnapshot + +func (pss PlayerSnapshots) ToDomain() (domain.PlayerSnapshots, error) { + res := make(domain.PlayerSnapshots, 0, len(pss)) + + for _, ps := range pss { + converted, err := ps.ToDomain() + if err != nil { + return nil, err + } + + res = append(res, converted) + } + + return res, nil +} diff --git a/internal/bun/buntest/fixture.go b/internal/bun/buntest/fixture.go index b1a2dce..2350f3a 100644 --- a/internal/bun/buntest/fixture.go +++ b/internal/bun/buntest/fixture.go @@ -24,6 +24,7 @@ func NewFixture(bunDB *bun.DB) *Fixture { (*bunmodel.Ennoblement)(nil), (*bunmodel.TribeChange)(nil), (*bunmodel.TribeSnapshot)(nil), + (*bunmodel.PlayerSnapshot)(nil), ) return &Fixture{ f: dbfixture.New(bunDB), diff --git a/internal/domain/player.go b/internal/domain/player.go index c04ce50..9833ce9 100644 --- a/internal/domain/player.go +++ b/internal/domain/player.go @@ -197,6 +197,10 @@ func (p Player) IsDeleted() bool { return !p.deletedAt.IsZero() } +func (p Player) IsZero() bool { + return p == Player{} +} + type Players []Player // Delete finds all players with the given serverKey that are not in the given slice with active players diff --git a/internal/domain/player_snapshot.go b/internal/domain/player_snapshot.go new file mode 100644 index 0000000..3a1319b --- /dev/null +++ b/internal/domain/player_snapshot.go @@ -0,0 +1,289 @@ +package domain + +import ( + "fmt" + "math" + "time" +) + +type PlayerSnapshot struct { + id int + playerID int + serverKey string + numVillages int + points int + rank int + tribeID int + od OpponentsDefeated + date time.Time + createdAt time.Time +} + +const playerSnapshotModelName = "PlayerSnapshot" + +// UnmarshalPlayerSnapshotFromDatabase unmarshals PlayerSnapshot from the database. +// +// It should be used only for unmarshalling from the database! +// You can't use UnmarshalPlayerSnapshotFromDatabase as constructor - It may put domain into the invalid state! +func UnmarshalPlayerSnapshotFromDatabase( + id int, + playerID int, + serverKey string, + numVillages int, + points int, + rank int, + tribeID int, + od OpponentsDefeated, + date time.Time, + createdAt time.Time, +) (PlayerSnapshot, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return PlayerSnapshot{}, ValidationError{ + Model: playerSnapshotModelName, + Field: "id", + Err: err, + } + } + + if err := validateIntInRange(playerID, 1, math.MaxInt); err != nil { + return PlayerSnapshot{}, ValidationError{ + Model: playerSnapshotModelName, + Field: "playerID", + Err: err, + } + } + + if err := validateServerKey(serverKey); err != nil { + return PlayerSnapshot{}, ValidationError{ + Model: playerSnapshotModelName, + Field: "serverKey", + Err: err, + } + } + + return PlayerSnapshot{ + id: id, + playerID: playerID, + serverKey: serverKey, + numVillages: numVillages, + points: points, + rank: rank, + tribeID: tribeID, + od: od, + date: date, + createdAt: createdAt, + }, nil +} + +func (ps PlayerSnapshot) ID() int { + return ps.id +} + +func (ps PlayerSnapshot) PlayerID() int { + return ps.playerID +} + +func (ps PlayerSnapshot) ServerKey() string { + return ps.serverKey +} + +func (ps PlayerSnapshot) NumVillages() int { + return ps.numVillages +} + +func (ps PlayerSnapshot) Points() int { + return ps.points +} + +func (ps PlayerSnapshot) Rank() int { + return ps.rank +} + +func (ps PlayerSnapshot) TribeID() int { + return ps.tribeID +} + +func (ps PlayerSnapshot) OD() OpponentsDefeated { + return ps.od +} + +func (ps PlayerSnapshot) Date() time.Time { + return ps.date +} + +func (ps PlayerSnapshot) CreatedAt() time.Time { + return ps.createdAt +} + +type PlayerSnapshots []PlayerSnapshot + +type CreatePlayerSnapshotParams struct { + playerID int + serverKey string + numVillages int + points int + rank int + tribeID int + od OpponentsDefeated + date time.Time +} + +func NewCreatePlayerSnapshotParams(players Players, date time.Time) ([]CreatePlayerSnapshotParams, error) { + params := make([]CreatePlayerSnapshotParams, 0, len(players)) + + for i, p := range players { + if p.IsZero() { + return nil, fmt.Errorf("players[%d] is an empty struct", i) + } + + if p.IsDeleted() { + continue + } + + params = append(params, CreatePlayerSnapshotParams{ + playerID: p.ID(), + serverKey: p.ServerKey(), + numVillages: p.NumVillages(), + points: p.Points(), + rank: p.Rank(), + tribeID: p.TribeID(), + od: p.OD(), + date: date, + }) + } + + return params, nil +} + +func (params CreatePlayerSnapshotParams) PlayerID() int { + return params.playerID +} + +func (params CreatePlayerSnapshotParams) ServerKey() string { + return params.serverKey +} + +func (params CreatePlayerSnapshotParams) NumVillages() int { + return params.numVillages +} + +func (params CreatePlayerSnapshotParams) Points() int { + return params.points +} + +func (params CreatePlayerSnapshotParams) Rank() int { + return params.rank +} + +func (params CreatePlayerSnapshotParams) TribeID() int { + return params.tribeID +} + +func (params CreatePlayerSnapshotParams) OD() OpponentsDefeated { + return params.od +} + +func (params CreatePlayerSnapshotParams) Date() time.Time { + return params.date +} + +type PlayerSnapshotSort uint8 + +const ( + PlayerSnapshotSortDateASC PlayerSnapshotSort = iota + 1 + PlayerSnapshotSortDateDESC + PlayerSnapshotSortIDASC + PlayerSnapshotSortIDDESC + PlayerSnapshotSortServerKeyASC + PlayerSnapshotSortServerKeyDESC +) + +const PlayerSnapshotListMaxLimit = 200 + +type ListPlayerSnapshotsParams struct { + serverKeys []string + sort []PlayerSnapshotSort + limit int + offset int +} + +const listPlayerSnapshotsParamsModelName = "ListPlayerSnapshotsParams" + +func NewListPlayerSnapshotsParams() ListPlayerSnapshotsParams { + return ListPlayerSnapshotsParams{ + sort: []PlayerSnapshotSort{ + PlayerSnapshotSortServerKeyASC, + PlayerSnapshotSortDateASC, + PlayerSnapshotSortIDASC, + }, + limit: PlayerSnapshotListMaxLimit, + } +} + +func (params *ListPlayerSnapshotsParams) ServerKeys() []string { + return params.serverKeys +} + +func (params *ListPlayerSnapshotsParams) SetServerKeys(serverKeys []string) error { + params.serverKeys = serverKeys + return nil +} + +func (params *ListPlayerSnapshotsParams) Sort() []PlayerSnapshotSort { + return params.sort +} + +const ( + playerSnapshotSortMinLength = 1 + playerSnapshotSortMaxLength = 3 +) + +func (params *ListPlayerSnapshotsParams) SetSort(sort []PlayerSnapshotSort) error { + if err := validateSliceLen(sort, playerSnapshotSortMinLength, playerSnapshotSortMaxLength); err != nil { + return ValidationError{ + Model: listPlayerSnapshotsParamsModelName, + Field: "sort", + Err: err, + } + } + + params.sort = sort + + return nil +} + +func (params *ListPlayerSnapshotsParams) Limit() int { + return params.limit +} + +func (params *ListPlayerSnapshotsParams) SetLimit(limit int) error { + if err := validateIntInRange(limit, 1, PlayerSnapshotListMaxLimit); err != nil { + return ValidationError{ + Model: listPlayerSnapshotsParamsModelName, + Field: "limit", + Err: err, + } + } + + params.limit = limit + + return nil +} + +func (params *ListPlayerSnapshotsParams) Offset() int { + return params.offset +} + +func (params *ListPlayerSnapshotsParams) SetOffset(offset int) error { + if err := validateIntInRange(offset, 0, math.MaxInt); err != nil { + return ValidationError{ + Model: listPlayerSnapshotsParamsModelName, + Field: "offset", + Err: err, + } + } + + params.offset = offset + + return nil +} diff --git a/internal/domain/player_snapshot_test.go b/internal/domain/player_snapshot_test.go new file mode 100644 index 0000000..8b5d6da --- /dev/null +++ b/internal/domain/player_snapshot_test.go @@ -0,0 +1,236 @@ +package domain_test + +import ( + "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 TestNewCreatePlayerSnapshotParams(t *testing.T) { + t.Parallel() + + players := domain.Players{ + domaintest.NewPlayer(t), + domaintest.NewPlayer(t), + domaintest.NewPlayer(t), + } + date := time.Now() + + res, err := domain.NewCreatePlayerSnapshotParams(players, date) + require.NoError(t, err) + assert.Len(t, res, len(players)) + for i, p := range players { + idx := slices.IndexFunc(res, func(params domain.CreatePlayerSnapshotParams) bool { + return params.PlayerID() == p.ID() && params.ServerKey() == p.ServerKey() + }) + require.GreaterOrEqualf(t, idx, 0, "players[%d] not found", i) + + params := res[i] + + assert.Equalf(t, p.ID(), params.PlayerID(), "players[%d]", i) + assert.Equalf(t, p.ServerKey(), params.ServerKey(), "players[%d]", i) + assert.Equalf(t, p.NumVillages(), params.NumVillages(), "players[%d]", i) + assert.Equalf(t, p.Points(), params.Points(), "players[%d]", i) + assert.Equalf(t, p.Rank(), params.Rank(), "players[%d]", i) + assert.Equalf(t, p.TribeID(), params.TribeID(), "players[%d]", i) + assert.Equalf(t, p.OD(), params.OD(), "players[%d]", i) + assert.Equalf(t, date, params.Date(), "players[%d]", i) + } +} + +func TestListPlayerSnapshotsParams_SetSort(t *testing.T) { + t.Parallel() + + type args struct { + sort []domain.PlayerSnapshotSort + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + sort: []domain.PlayerSnapshotSort{ + domain.PlayerSnapshotSortDateASC, + domain.PlayerSnapshotSortServerKeyASC, + }, + }, + }, + { + name: "ERR: len(sort) < 1", + args: args{ + sort: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 3, + Current: 0, + }, + }, + }, + { + name: "ERR: len(sort) > 3", + args: args{ + sort: []domain.PlayerSnapshotSort{ + domain.PlayerSnapshotSortDateASC, + domain.PlayerSnapshotSortServerKeyASC, + domain.PlayerSnapshotSortIDASC, + domain.PlayerSnapshotSortIDDESC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 3, + Current: 4, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListPlayerSnapshotsParams() + + require.ErrorIs(t, params.SetSort(tt.args.sort), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.sort, params.Sort()) + }) + } +} + +func TestListPlayerSnapshotsParams_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.PlayerSnapshotListMaxLimit, + }, + }, + { + name: "ERR: limit < 1", + args: args{ + limit: 0, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayerSnapshotsParams", + Field: "limit", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + { + name: fmt.Sprintf("ERR: limit > %d", domain.PlayerSnapshotListMaxLimit), + args: args{ + limit: domain.PlayerSnapshotListMaxLimit + 1, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayerSnapshotsParams", + Field: "limit", + Err: domain.MaxLessEqualError{ + Max: domain.PlayerSnapshotListMaxLimit, + Current: domain.PlayerSnapshotListMaxLimit + 1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListPlayerSnapshotsParams() + + require.ErrorIs(t, params.SetLimit(tt.args.limit), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.limit, params.Limit()) + }) + } +} + +func TestListPlayerSnapshotsParams_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: "ListPlayerSnapshotsParams", + 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.NewListPlayerSnapshotsParams() + + 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/server.go b/internal/domain/server.go index bf4788c..c12e6f6 100644 --- a/internal/domain/server.go +++ b/internal/domain/server.go @@ -274,20 +274,21 @@ func (params CreateServerParams) VersionCode() string { } type UpdateServerParams struct { - config NullServerConfig - buildingInfo NullBuildingInfo - unitInfo NullUnitInfo - numTribes NullInt - tribeDataSyncedAt NullTime - numPlayers NullInt - playerDataSyncedAt NullTime - numVillages NullInt - numPlayerVillages NullInt - numBarbarianVillages NullInt - numBonusVillages NullInt - villageDataSyncedAt NullTime - ennoblementDataSyncedAt NullTime - tribeSnapshotsCreatedAt NullTime + config NullServerConfig + buildingInfo NullBuildingInfo + unitInfo NullUnitInfo + numTribes NullInt + tribeDataSyncedAt NullTime + numPlayers NullInt + playerDataSyncedAt NullTime + numVillages NullInt + numPlayerVillages NullInt + numBarbarianVillages NullInt + numBonusVillages NullInt + villageDataSyncedAt NullTime + ennoblementDataSyncedAt NullTime + tribeSnapshotsCreatedAt NullTime + playerSnapshotsCreatedAt NullTime } const updateServerParamsModelName = "UpdateServerParams" @@ -429,6 +430,15 @@ func (params *UpdateServerParams) SetTribeSnapshotsCreatedAt(tribeSnapshotsCreat return nil } +func (params *UpdateServerParams) PlayerSnapshotsCreatedAt() NullTime { + return params.playerSnapshotsCreatedAt +} + +func (params *UpdateServerParams) SetPlayerSnapshotsCreatedAt(playerSnapshotsCreatedAt NullTime) error { + params.playerSnapshotsCreatedAt = playerSnapshotsCreatedAt + return nil +} + //nolint:gocyclo func (params *UpdateServerParams) IsZero() bool { return !params.config.Valid && @@ -441,7 +451,8 @@ func (params *UpdateServerParams) IsZero() bool { !params.numVillages.Valid && !params.villageDataSyncedAt.Valid && !params.ennoblementDataSyncedAt.Valid && - !params.tribeSnapshotsCreatedAt.Valid + !params.tribeSnapshotsCreatedAt.Valid && + !params.playerSnapshotsCreatedAt.Valid } type ServerSort uint8 diff --git a/internal/domain/tribe_snapshot.go b/internal/domain/tribe_snapshot.go index fbab562..73db13c 100644 --- a/internal/domain/tribe_snapshot.go +++ b/internal/domain/tribe_snapshot.go @@ -49,6 +49,14 @@ func UnmarshalTribeSnapshotFromDatabase( } } + if err := validateIntInRange(tribeID, 1, math.MaxInt); err != nil { + return TribeSnapshot{}, ValidationError{ + Model: tribeSnapshotModelName, + Field: "tribeID", + Err: err, + } + } + if err := validateServerKey(serverKey); err != nil { return TribeSnapshot{}, ValidationError{ Model: tribeSnapshotModelName, diff --git a/internal/port/consumer_watermill_player.go b/internal/port/consumer_watermill_player.go index 55021e3..d326736 100644 --- a/internal/port/consumer_watermill_player.go +++ b/internal/port/consumer_watermill_player.go @@ -9,26 +9,32 @@ import ( ) type PlayerWatermillConsumer struct { - svc *app.PlayerService - subscriber message.Subscriber - logger watermill.LoggerAdapter - marshaler watermillmsg.Marshaler - eventServerSyncedTopic string + svc *app.PlayerService + snapshotSvc *app.PlayerSnapshotService + subscriber message.Subscriber + logger watermill.LoggerAdapter + marshaler watermillmsg.Marshaler + eventServerSyncedTopic string + cmdCreateSnapshotsTopic string } func NewPlayerWatermillConsumer( svc *app.PlayerService, + snapshotSvc *app.PlayerSnapshotService, subscriber message.Subscriber, logger watermill.LoggerAdapter, marshaler watermillmsg.Marshaler, eventServerSyncedTopic string, + cmdCreateSnapshotsTopic string, ) *PlayerWatermillConsumer { return &PlayerWatermillConsumer{ - svc: svc, - subscriber: subscriber, - logger: logger, - marshaler: marshaler, - eventServerSyncedTopic: eventServerSyncedTopic, + svc: svc, + snapshotSvc: snapshotSvc, + subscriber: subscriber, + logger: logger, + marshaler: marshaler, + eventServerSyncedTopic: eventServerSyncedTopic, + cmdCreateSnapshotsTopic: cmdCreateSnapshotsTopic, } } @@ -39,6 +45,12 @@ func (c *PlayerWatermillConsumer) Register(router *message.Router) { c.subscriber, c.sync, ) + router.AddNoPublisherHandler( + "PlayerConsumer.createSnapshots", + c.cmdCreateSnapshotsTopic, + c.subscriber, + c.createSnapshots, + ) } func (c *PlayerWatermillConsumer) sync(msg *message.Message) error { @@ -61,3 +73,29 @@ func (c *PlayerWatermillConsumer) sync(msg *message.Message) error { return c.svc.Sync(msg.Context(), payload) } + +func (c *PlayerWatermillConsumer) createSnapshots(msg *message.Message) error { + var rawPayload watermillmsg.CreateSnapshotsCmdPayload + + 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.NewCreateSnapshotsCmdPayload( + rawPayload.ServerKey, + rawPayload.VersionCode, + rawPayload.VersionTimezone, + rawPayload.Date, + ) + if err != nil { + c.logger.Error("couldn't construct domain.CreateSnapshotsCmdPayload", err, watermill.LogFields{ + "handler": message.HandlerNameFromCtx(msg.Context()), + }) + return nil + } + + return c.snapshotSvc.Create(msg.Context(), payload) +} diff --git a/internal/port/consumer_watermill_server.go b/internal/port/consumer_watermill_server.go index cb146a7..a386473 100644 --- a/internal/port/consumer_watermill_server.go +++ b/internal/port/consumer_watermill_server.go @@ -9,17 +9,18 @@ import ( ) type ServerWatermillConsumer struct { - svc *app.ServerService - subscriber message.Subscriber - logger watermill.LoggerAdapter - marshaler watermillmsg.Marshaler - cmdSyncTopic string - eventServerSyncedTopic string - eventTribesSyncedTopic string - eventPlayersSyncedTopic string - eventVillagesSyncedTopic string - eventEnnoblementsSyncedTopic string - eventTribeSnapshotsCreatedTopic string + svc *app.ServerService + subscriber message.Subscriber + logger watermill.LoggerAdapter + marshaler watermillmsg.Marshaler + cmdSyncTopic string + eventServerSyncedTopic string + eventTribesSyncedTopic string + eventPlayersSyncedTopic string + eventVillagesSyncedTopic string + eventEnnoblementsSyncedTopic string + eventTribeSnapshotsCreatedTopic string + eventPlayerSnapshotsCreatedTopic string } func NewServerWatermillConsumer( @@ -34,19 +35,21 @@ func NewServerWatermillConsumer( eventVillagesSyncedTopic string, eventEnnoblementsSyncedTopic string, eventTribeSnapshotsCreatedTopic string, + eventPlayerSnapshotsCreatedTopic string, ) *ServerWatermillConsumer { return &ServerWatermillConsumer{ - svc: svc, - subscriber: subscriber, - logger: logger, - marshaler: marshaler, - cmdSyncTopic: cmdSyncTopic, - eventServerSyncedTopic: eventServerSyncedTopic, - eventTribesSyncedTopic: eventTribesSyncedTopic, - eventPlayersSyncedTopic: eventPlayersSyncedTopic, - eventVillagesSyncedTopic: eventVillagesSyncedTopic, - eventEnnoblementsSyncedTopic: eventEnnoblementsSyncedTopic, - eventTribeSnapshotsCreatedTopic: eventTribeSnapshotsCreatedTopic, + svc: svc, + subscriber: subscriber, + logger: logger, + marshaler: marshaler, + cmdSyncTopic: cmdSyncTopic, + eventServerSyncedTopic: eventServerSyncedTopic, + eventTribesSyncedTopic: eventTribesSyncedTopic, + eventPlayersSyncedTopic: eventPlayersSyncedTopic, + eventVillagesSyncedTopic: eventVillagesSyncedTopic, + eventEnnoblementsSyncedTopic: eventEnnoblementsSyncedTopic, + eventTribeSnapshotsCreatedTopic: eventTribeSnapshotsCreatedTopic, + eventPlayerSnapshotsCreatedTopic: eventPlayerSnapshotsCreatedTopic, } } @@ -88,6 +91,12 @@ func (c *ServerWatermillConsumer) Register(router *message.Router) { c.subscriber, c.updateTribeSnapshotsCreatedAt, ) + router.AddNoPublisherHandler( + "ServerConsumer.updatePlayerSnapshotsCreatedAt", + c.eventPlayerSnapshotsCreatedTopic, + c.subscriber, + c.updatePlayerSnapshotsCreatedAt, + ) } func (c *ServerWatermillConsumer) sync(msg *message.Message) error { @@ -261,3 +270,27 @@ func (c *ServerWatermillConsumer) updateTribeSnapshotsCreatedAt(msg *message.Mes return c.svc.UpdateTribeSnapshotsCreatedAt(msg.Context(), payload) } + +func (c *ServerWatermillConsumer) updatePlayerSnapshotsCreatedAt(msg *message.Message) error { + var rawPayload watermillmsg.SnapshotsCreatedEventPayload + + 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.NewSnapshotsCreatedEventPayload( + rawPayload.ServerKey, + rawPayload.VersionCode, + ) + if err != nil { + c.logger.Error("couldn't construct domain.SnapshotsCreatedEventPayload", err, watermill.LogFields{ + "handler": message.HandlerNameFromCtx(msg.Context()), + }) + return nil + } + + return c.svc.UpdatePlayerSnapshotsCreatedAt(msg.Context(), payload) +} diff --git a/internal/service/data_sync_test.go b/internal/service/data_sync_test.go index bfa394f..6f3c78d 100644 --- a/internal/service/data_sync_test.go +++ b/internal/service/data_sync_test.go @@ -167,6 +167,7 @@ func TestDataSync(t *testing.T) { villageEventSynced, "", "", + "", ), port.NewTribeWatermillConsumer( tribeSvc, @@ -178,7 +179,15 @@ func TestDataSync(t *testing.T) { villageEventSynced, "", ), - port.NewPlayerWatermillConsumer(playerSvc, playerSub, nopLogger, marshaler, serverEventSynced), + port.NewPlayerWatermillConsumer( + playerSvc, + nil, + playerSub, + nopLogger, + marshaler, + serverEventSynced, + "", + ), port.NewVillageWatermillConsumer(villageSvc, villageSub, nopLogger, marshaler, serverEventSynced), ) diff --git a/internal/service/ennoblement_sync_test.go b/internal/service/ennoblement_sync_test.go index ce20348..dc4d75f 100644 --- a/internal/service/ennoblement_sync_test.go +++ b/internal/service/ennoblement_sync_test.go @@ -148,6 +148,7 @@ func TestEnnoblementSync(t *testing.T) { "", ennoblementEventSynced, "", + "", ), port.NewEnnoblementWatermillConsumer(ennoblementSvc, ennoblementSub, nopLogger, marshaler, ennoblementCmdSync), )