diff --git a/.golangci.yml b/.golangci.yml index 7ebfd70..3d64ebd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -509,7 +509,7 @@ issues: text: add-constant - path: bun/migrations linters: - - init + - gochecknoinits - linters: - lll source: "^//go:generate " diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85c41fa..f73de22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: stages: [commit-msg] additional_dependencies: ["@commitlint/config-conventional"] - repo: https://github.com/golangci/golangci-lint - rev: v1.58.0 + rev: v1.58.1 hooks: - id: golangci-lint - repo: https://github.com/hadolint/hadolint diff --git a/Makefile b/Makefile index 9dc8d8e..227c9cc 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ install-git-hooks: .PHONY: install-golangci-lint install-golangci-lint: @echo "Installing github.com/golangci/golangci-lint..." - @(test -f $(GOLANGCI_LINT_PATH) && echo "github.com/golangci/golangci-lint is already installed. Skipping...") || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v1.58.0 + @(test -f $(GOLANGCI_LINT_PATH) && echo "github.com/golangci/golangci-lint is already installed. Skipping...") || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v1.58.1 .PHONY: install-oapi-codegen install-oapi-codegen: diff --git a/cmd/twhelp/cmd_consumer.go b/cmd/twhelp/cmd_consumer.go index 5ec6537..f29984f 100644 --- a/cmd/twhelp/cmd_consumer.go +++ b/cmd/twhelp/cmd_consumer.go @@ -49,18 +49,32 @@ var cmdConsumer = &cli.Command{ c.String(rmqFlagTopicSyncServersCmd.Name), c.String(rmqFlagTopicServerSyncedEvent.Name), ) + serverSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( + publisher, + marshaler, + c.String(rmqFlagTopicCreateServerSnapshotCmd.Name), + "", + ) twSvc, err := newTWServiceFromFlags(c) if err != nil { return err } + serverSvc := app.NewServerService(adapter.NewServerBunRepository(db), twSvc, serverPublisher) + serverSnapshotSvc := app.NewServerSnapshotService( + adapter.NewServerSnapshotBunRepository(db), + serverSvc, + serverSnapshotPublisher, + ) consumer := port.NewServerWatermillConsumer( - app.NewServerService(adapter.NewServerBunRepository(db), twSvc, serverPublisher), + serverSvc, + serverSnapshotSvc, subscriber, logger, marshaler, c.String(rmqFlagTopicSyncServersCmd.Name), + c.String(rmqFlagTopicCreateServerSnapshotCmd.Name), c.String(rmqFlagTopicServerSyncedEvent.Name), c.String(rmqFlagTopicTribesSyncedEvent.Name), c.String(rmqFlagTopicPlayersSyncedEvent.Name), diff --git a/cmd/twhelp/cmd_job.go b/cmd/twhelp/cmd_job.go index 85c169a..db51721 100644 --- a/cmd/twhelp/cmd_job.go +++ b/cmd/twhelp/cmd_job.go @@ -171,13 +171,18 @@ var ( } defer closeBunDB(bunDB, logger) + serverSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( + publisher, + newWatermillMarshaler(), + c.String(rmqFlagTopicCreateServerSnapshotCmd.Name), + "", + ) tribeSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( publisher, newWatermillMarshaler(), c.String(rmqFlagTopicCreateTribeSnapshotsCmd.Name), c.String(rmqFlagTopicTribeSnapshotsCreatedEvent.Name), ) - playerSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( publisher, newWatermillMarshaler(), @@ -187,7 +192,13 @@ var ( versionSvc := app.NewVersionService(adapter.NewVersionBunRepository(bunDB)) serverSvc := app.NewServerService(adapter.NewServerBunRepository(bunDB), nil, nil) - snapshotSvc := app.NewSnapshotService(versionSvc, serverSvc, tribeSnapshotPublisher, playerSnapshotPublisher) + snapshotSvc := app.NewSnapshotService( + versionSvc, + serverSvc, + serverSnapshotPublisher, + tribeSnapshotPublisher, + playerSnapshotPublisher, + ) shutdownSignalCtx, stop := newShutdownSignalContext(c.Context) defer stop() diff --git a/cmd/twhelp/rabbitmq.go b/cmd/twhelp/rabbitmq.go index 1480d8f..dd7e7ca 100644 --- a/cmd/twhelp/rabbitmq.go +++ b/cmd/twhelp/rabbitmq.go @@ -31,6 +31,11 @@ var ( Value: "tribes.event.synced", EnvVars: []string{"RABBITMQ_TOPIC_TRIBES_SYNCED_EVENT"}, } + rmqFlagTopicCreateServerSnapshotCmd = &cli.StringFlag{ + Name: "rabbitmq.topic.createServerSnapshotCmd", + Value: "servers.cmd.create_snapshot", + EnvVars: []string{"RABBITMQ_TOPIC_CREATE_SERVER_SNAPSHOT_CMD"}, + } rmqFlagTopicCreateTribeSnapshotsCmd = &cli.StringFlag{ Name: "rabbitmq.topic.createTribeSnapshotsCmd", Value: "tribes.cmd.create_snapshots", @@ -79,6 +84,7 @@ var ( rmqFlags = []cli.Flag{ rmqFlagConnectionString, rmqFlagTopicSyncServersCmd, + rmqFlagTopicCreateServerSnapshotCmd, rmqFlagTopicServerSyncedEvent, rmqFlagTopicTribesSyncedEvent, rmqFlagTopicCreateTribeSnapshotsCmd, diff --git a/internal/adapter/repository_bun_ennoblement.go b/internal/adapter/repository_bun_ennoblement.go index 5312ba5..109c941 100644 --- a/internal/adapter/repository_bun_ennoblement.go +++ b/internal/adapter/repository_bun_ennoblement.go @@ -111,7 +111,7 @@ func (repo *EnnoblementBunRepository) ListWithRelations( func (repo *EnnoblementBunRepository) Delete(ctx context.Context, serverKey string, createdAtLTE time.Time) error { if _, err := repo.db.NewDelete(). - Model(&bunmodel.Ennoblement{}). + Model((*bunmodel.Ennoblement)(nil)). Where("server_key = ?", serverKey). Where("created_at <= ?", createdAtLTE). Returning("NULL"). diff --git a/internal/adapter/repository_bun_player_snapshot.go b/internal/adapter/repository_bun_player_snapshot.go index 90b8be3..568c2c5 100644 --- a/internal/adapter/repository_bun_player_snapshot.go +++ b/internal/adapter/repository_bun_player_snapshot.go @@ -109,7 +109,7 @@ func (repo *PlayerSnapshotBunRepository) ListWithRelations( func (repo *PlayerSnapshotBunRepository) Delete(ctx context.Context, serverKey string, dateLTE time.Time) error { if _, err := repo.db.NewDelete(). - Model(&bunmodel.PlayerSnapshot{}). + Model((*bunmodel.PlayerSnapshot)(nil)). Where("server_key = ?", serverKey). Where("date <= ?", dateLTE). Returning("NULL"). diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index 976fd53..19544ba 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -182,6 +182,10 @@ func (a updateServerParamsApplier) apply(q *bun.UpdateQuery) *bun.UpdateQuery { q = q.Set("ennoblement_data_synced_at = ?", ennoblementDataSyncedAt.V) } + if snapshotCreatedAt := a.params.SnapshotCreatedAt(); snapshotCreatedAt.Valid { + q = q.Set("snapshot_created_at = ?", snapshotCreatedAt.V) + } + if tribeSnapshotsCreatedAt := a.params.TribeSnapshotsCreatedAt(); tribeSnapshotsCreatedAt.Valid { q = q.Set("tribe_snapshots_created_at = ?", tribeSnapshotsCreatedAt.V) } @@ -228,6 +232,13 @@ func (a listServersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { ) } + if snapshotCreatedAt := a.params.SnapshotCreatedAtLT(); snapshotCreatedAt.Valid { + q = q.Where( + "server.snapshot_created_at < ? OR server.snapshot_created_at is null", + snapshotCreatedAt.V, + ) + } + for _, s := range a.params.Sort() { column, dir, err := a.sortToColumnAndDirection(s) if err != nil { diff --git a/internal/adapter/repository_bun_server_snapshot.go b/internal/adapter/repository_bun_server_snapshot.go new file mode 100644 index 0000000..9be39c6 --- /dev/null +++ b/internal/adapter/repository_bun_server_snapshot.go @@ -0,0 +1,179 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/core/internal/bun/bunmodel" + "gitea.dwysokinski.me/twhelp/core/internal/domain" + "github.com/uptrace/bun" +) + +type ServerSnapshotBunRepository struct { + db bun.IDB +} + +func NewServerSnapshotBunRepository(db bun.IDB) *ServerSnapshotBunRepository { + return &ServerSnapshotBunRepository{db: db} +} + +func (repo *ServerSnapshotBunRepository) Create( + ctx context.Context, + params ...domain.CreateServerSnapshotParams, +) error { + if len(params) == 0 { + return nil + } + + now := time.Now() + snapshots := make(bunmodel.ServerSnapshots, 0, len(params)) + + for _, p := range params { + snapshots = append(snapshots, bunmodel.ServerSnapshot{ + ServerKey: p.ServerKey(), + NumPlayers: p.NumPlayers(), + NumActivePlayers: p.NumActivePlayers(), + NumInactivePlayers: p.NumInactivePlayers(), + NumTribes: p.NumTribes(), + NumActiveTribes: p.NumActiveTribes(), + NumInactiveTribes: p.NumInactiveTribes(), + NumVillages: p.NumVillages(), + NumPlayerVillages: p.NumPlayerVillages(), + NumBarbarianVillages: p.NumBarbarianVillages(), + NumBonusVillages: p.NumBonusVillages(), + Date: p.Date(), + CreatedAt: now, + }) + } + + if _, err := repo.db.NewInsert(). + Model(&snapshots). + Ignore(). + Returning(""). + Exec(ctx); err != nil { + return fmt.Errorf("something went wrong while inserting server snapshots into the db: %w", err) + } + + return nil +} + +func (repo *ServerSnapshotBunRepository) List( + ctx context.Context, + params domain.ListServerSnapshotsParams, +) (domain.ListServerSnapshotsResult, error) { + var serverSnapshots bunmodel.ServerSnapshots + + if err := repo.db.NewSelect(). + Model(&serverSnapshots). + Apply(listServerSnapshotsParamsApplier{params: params}.apply). + Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { + return domain.ListServerSnapshotsResult{}, fmt.Errorf("couldn't select server snapshots from the db: %w", err) + } + + converted, err := serverSnapshots.ToDomain() + if err != nil { + return domain.ListServerSnapshotsResult{}, err + } + + return domain.NewListServerSnapshotsResult(separateListResultAndNext(converted, params.Limit())) +} + +func (repo *ServerSnapshotBunRepository) Delete(ctx context.Context, serverKey string, dateLTE time.Time) error { + if _, err := repo.db.NewDelete(). + Model((*bunmodel.ServerSnapshot)(nil)). + Where("server_key = ?", serverKey). + Where("date <= ?", dateLTE). + Returning("NULL"). + Exec(ctx); err != nil { + return fmt.Errorf("couldn't delete server snapshots: %w", err) + } + + return nil +} + +type listServerSnapshotsParamsApplier struct { + params domain.ListServerSnapshotsParams +} + +func (a listServerSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { + if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 { + q = q.Where("ss.server_key IN (?)", bun.In(serverKeys)) + } + + for _, s := range a.params.Sort() { + column, dir, err := a.sortToColumnAndDirection(s) + if err != nil { + return q.Err(err) + } + + q.OrderExpr("? ?", column, dir.Bun()) + } + + return q.Limit(a.params.Limit() + 1).Apply(a.applyCursor) +} + +func (a listServerSnapshotsParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { + cursor := a.params.Cursor() + + if cursor.IsZero() { + return q + } + + sort := a.params.Sort() + cursorApplier := cursorPaginationApplier{ + data: make([]cursorPaginationApplierDataElement, 0, len(sort)), + } + + for _, s := range sort { + var err error + var el cursorPaginationApplierDataElement + + el.column, el.direction, err = a.sortToColumnAndDirection(s) + if err != nil { + return q.Err(err) + } + + switch s { + case domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortIDDESC: + el.value = cursor.ID() + el.unique = true + case domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortServerKeyDESC: + el.value = cursor.ServerKey() + case domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateDESC: + el.value = cursor.Date() + default: + return q.Err(fmt.Errorf("%s: %w", s.String(), errInvalidSortValue)) + } + + cursorApplier.data = append(cursorApplier.data, el) + } + + return q.Apply(cursorApplier.apply) +} + +func (a listServerSnapshotsParamsApplier) sortToColumnAndDirection( + s domain.ServerSnapshotSort, +) (bun.Safe, sortDirection, error) { + switch s { + case domain.ServerSnapshotSortDateASC: + return "ss.date", sortDirectionASC, nil + case domain.ServerSnapshotSortDateDESC: + return "ss.date", sortDirectionDESC, nil + case domain.ServerSnapshotSortIDASC: + return "ss.id", sortDirectionASC, nil + case domain.ServerSnapshotSortIDDESC: + return "ss.id", sortDirectionDESC, nil + case domain.ServerSnapshotSortServerKeyASC: + return "ss.server_key", sortDirectionASC, nil + case domain.ServerSnapshotSortServerKeyDESC: + return "ss.server_key", sortDirectionDESC, nil + default: + return "", 0, fmt.Errorf("%s: %w", s.String(), errInvalidSortValue) + } +} diff --git a/internal/adapter/repository_bun_server_snapshot_test.go b/internal/adapter/repository_bun_server_snapshot_test.go new file mode 100644 index 0000000..f17b38c --- /dev/null +++ b/internal/adapter/repository_bun_server_snapshot_test.go @@ -0,0 +1,29 @@ +package adapter_test + +import ( + "testing" + + "gitea.dwysokinski.me/twhelp/core/internal/bun/buntest" +) + +func TestServerSnapshotBunRepository_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long-running test") + } + + testServerSnapshotRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, postgres.NewDB(t)) + }) +} + +func TestServerSnapshotBunRepository_SQLite(t *testing.T) { + t.Parallel() + + testServerSnapshotRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, buntest.NewSQLiteDB(t)) + }) +} diff --git a/internal/adapter/repository_bun_tribe_snapshot.go b/internal/adapter/repository_bun_tribe_snapshot.go index 955e70d..8e00513 100644 --- a/internal/adapter/repository_bun_tribe_snapshot.go +++ b/internal/adapter/repository_bun_tribe_snapshot.go @@ -105,7 +105,7 @@ func (repo *TribeSnapshotBunRepository) ListWithRelations( func (repo *TribeSnapshotBunRepository) Delete(ctx context.Context, serverKey string, dateLTE time.Time) error { if _, err := repo.db.NewDelete(). - Model(&bunmodel.TribeSnapshot{}). + Model((*bunmodel.TribeSnapshot)(nil)). Where("server_key = ?", serverKey). Where("date <= ?", dateLTE). Returning("NULL"). diff --git a/internal/adapter/repository_player_snapshot_test.go b/internal/adapter/repository_player_snapshot_test.go index ef25392..0c627a3 100644 --- a/internal/adapter/repository_player_snapshot_test.go +++ b/internal/adapter/repository_player_snapshot_test.go @@ -71,12 +71,14 @@ func testPlayerSnapshotRepository(t *testing.T, newRepos func(t *testing.T) repo 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()) { + //nolint:lll + if ps.ServerKey() == p.ServerKey() && ps.PlayerID() == p.PlayerID() && ps.Date().Format(dateFormat) == p.Date().Format(dateFormat) { m[key] = append(m[key], i) } } } + assert.NotEmpty(t, m) for key, indexes := range m { assert.Len(t, indexes, 1, key) } diff --git a/internal/adapter/repository_server_snapshot_test.go b/internal/adapter/repository_server_snapshot_test.go new file mode 100644 index 0000000..a0ff482 --- /dev/null +++ b/internal/adapter/repository_server_snapshot_test.go @@ -0,0 +1,445 @@ +package adapter_test + +import ( + "cmp" + "context" + "slices" + "testing" + "time" + + "gitea.dwysokinski.me/twhelp/core/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testServerSnapshotRepository(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.CreateServerSnapshotParams) { + t.Helper() + + require.NotEmpty(t, params) + + listParams := domain.NewListServerSnapshotsParams() + require.NoError(t, listParams.SetServerKeys([]string{params.ServerKey()})) + + res, err := repos.serverSnapshot.List(ctx, listParams) + serverSnapshots := res.ServerSnapshots() + require.NoError(t, err) + + date := params.Date().Format(dateFormat) + + idx := slices.IndexFunc(serverSnapshots, func(ss domain.ServerSnapshot) bool { + return ss.ServerKey() == params.ServerKey() && + ss.Date().Format(dateFormat) == date + }) + require.GreaterOrEqual(t, idx, 0) + serverSnapshot := serverSnapshots[idx] + + assert.Equal(t, params.ServerKey(), serverSnapshot.ServerKey()) + assert.Equal(t, params.NumPlayers(), serverSnapshot.NumPlayers()) + assert.Equal(t, params.NumActivePlayers(), serverSnapshot.NumActivePlayers()) + assert.Equal(t, params.NumInactivePlayers(), serverSnapshot.NumInactivePlayers()) + assert.Equal(t, params.NumTribes(), serverSnapshot.NumTribes()) + assert.Equal(t, params.NumActiveTribes(), serverSnapshot.NumActiveTribes()) + assert.Equal(t, params.NumInactiveTribes(), serverSnapshot.NumInactiveTribes()) + assert.Equal(t, params.NumVillages(), serverSnapshot.NumVillages()) + assert.Equal(t, params.NumPlayerVillages(), serverSnapshot.NumPlayerVillages()) + assert.Equal(t, params.NumBarbarianVillages(), serverSnapshot.NumBarbarianVillages()) + assert.Equal(t, params.NumBonusVillages(), serverSnapshot.NumBonusVillages()) + assert.Equal(t, date, serverSnapshot.Date().Format(dateFormat)) + assert.WithinDuration(t, time.Now(), serverSnapshot.CreatedAt(), time.Minute) + } + + assertNoDuplicates := func(t *testing.T, params domain.CreateServerSnapshotParams) { + t.Helper() + + listParams := domain.NewListServerSnapshotsParams() + require.NoError(t, listParams.SetServerKeys([]string{params.ServerKey()})) + + res, err := repos.serverSnapshot.List(ctx, listParams) + require.NoError(t, err) + serverSnapshots := res.ServerSnapshots() + + var indexes []int + + for i, ss := range serverSnapshots { + if ss.ServerKey() == params.ServerKey() && ss.Date().Format(dateFormat) == params.Date().Format(dateFormat) { + indexes = append(indexes, i) + } + } + + assert.Len(t, indexes, 1) + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + listServersParams := domain.NewListServersParams() + require.NoError(t, listServersParams.SetOpen(domain.NullBool{ + V: true, + Valid: true, + })) + require.NoError(t, listServersParams.SetLimit(1)) + + res, err := repos.server.List(ctx, listServersParams) + require.NoError(t, err) + servers := res.Servers() + require.NotEmpty(t, servers) + + date := time.Now() + + createParams, err := domain.NewCreateServerSnapshotParams(servers[0], date) + require.NoError(t, err) + + require.NoError(t, repos.serverSnapshot.Create(ctx, createParams)) + assertCreated(t, createParams) + + require.NoError(t, repos.serverSnapshot.Create(ctx, createParams)) + assertNoDuplicates(t, createParams) + }) + + t.Run("OK: len(params) == 0", func(t *testing.T) { + t.Parallel() + + require.NoError(t, repos.serverSnapshot.Create(ctx)) + }) + }) + + t.Run("List", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + tests := []struct { + name string + params func(t *testing.T) domain.ListServerSnapshotsParams + assertResult func(t *testing.T, params domain.ListServerSnapshotsParams, res domain.ListServerSnapshotsResult) + assertError func(t *testing.T, err error) + }{ + { + name: "OK: default params", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + return domain.NewListServerSnapshotsParams() + }, + assertResult: func( + t *testing.T, + _ domain.ListServerSnapshotsParams, + res domain.ListServerSnapshotsResult, + ) { + t.Helper() + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Or( + cmp.Compare(a.ServerKey(), b.ServerKey()), + a.Date().Compare(b.Date()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + assert.False(t, res.Self().IsZero()) + assert.True(t, res.Next().IsZero()) + }, + }, + { + name: "OK: sort=[serverKey DESC, date DESC, id DESC]", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortServerKeyDESC, + domain.ServerSnapshotSortDateDESC, + domain.ServerSnapshotSortIDDESC, + })) + return params + }, + assertResult: func( + t *testing.T, + _ domain.ListServerSnapshotsParams, + res domain.ListServerSnapshotsResult, + ) { + t.Helper() + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Or( + cmp.Compare(a.ServerKey(), b.ServerKey()), + a.Date().Compare(b.Date()), + cmp.Compare(a.ID(), b.ID()), + ) * -1 + })) + }, + }, + { + name: "OK: sort=[id ASC]", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + })) + return params + }, + assertResult: func( + t *testing.T, + _ domain.ListServerSnapshotsParams, + res domain.ListServerSnapshotsResult, + ) { + t.Helper() + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Compare(a.ID(), b.ID()) + })) + }, + }, + { + name: "OK: sort=[id DESC]", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDDESC, + })) + return params + }, + assertResult: func( + t *testing.T, + _ domain.ListServerSnapshotsParams, + res domain.ListServerSnapshotsResult, + ) { + t.Helper() + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Compare(a.ID(), b.ID()) * -1 + })) + }, + }, + { + name: "OK: serverKeys", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + + params := domain.NewListServerSnapshotsParams() + + res, err := repos.serverSnapshot.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.ServerSnapshots()) + randServerSnapshot := res.ServerSnapshots()[0] + + require.NoError(t, params.SetServerKeys([]string{randServerSnapshot.ServerKey()})) + + return params + }, + assertResult: func( + t *testing.T, + params domain.ListServerSnapshotsParams, + res domain.ListServerSnapshotsResult, + ) { + t.Helper() + + serverKeys := params.ServerKeys() + + serverSnapshots := res.ServerSnapshots() + assert.NotZero(t, serverSnapshots) + for _, ss := range serverSnapshots { + assert.True(t, slices.Contains(serverKeys, ss.ServerKey())) + } + }, + }, + { + name: "OK: cursor serverKeys sort=[id ASC]", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + + params := domain.NewListServerSnapshotsParams() + + res, err := repos.serverSnapshot.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.ServerSnapshots()), 2) + + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{domain.ServerSnapshotSortIDASC})) + require.NoError(t, params.SetServerKeys([]string{res.ServerSnapshots()[1].ServerKey()})) + cursor, err := res.ServerSnapshots()[1].ToCursor() + require.NoError(t, err) + require.NoError(t, params.SetCursor(cursor)) + + return params + }, + assertResult: func(t *testing.T, params domain.ListServerSnapshotsParams, res domain.ListServerSnapshotsResult) { + t.Helper() + + serverKeys := params.ServerKeys() + + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + for _, ss := range serverSnapshots { + assert.GreaterOrEqual(t, ss.ID(), params.Cursor().ID()) + assert.True(t, slices.Contains(serverKeys, ss.ServerKey())) + } + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Compare(a.ID(), b.ID()) + })) + }, + }, + { + name: "OK: cursor sort=[serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortIDASC, + })) + + res, err := repos.serverSnapshot.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.ServerSnapshots()), 2) + + cursor, err := res.ServerSnapshots()[1].ToCursor() + require.NoError(t, err) + require.NoError(t, params.SetCursor(cursor)) + + return params + }, + assertResult: func(t *testing.T, params domain.ListServerSnapshotsParams, res domain.ListServerSnapshotsResult) { + t.Helper() + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Or( + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + assert.GreaterOrEqual(t, serverSnapshots[0].ID(), params.Cursor().ID()) + for _, ss := range serverSnapshots { + assert.GreaterOrEqual(t, ss.ServerKey(), params.Cursor().ServerKey()) + } + }, + }, + { + name: "OK: cursor sort=[serverKey DESC, id DESC]", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortServerKeyDESC, + domain.ServerSnapshotSortIDDESC, + })) + + res, err := repos.serverSnapshot.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.ServerSnapshots()), 2) + + cursor, err := res.ServerSnapshots()[1].ToCursor() + require.NoError(t, err) + require.NoError(t, params.SetCursor(cursor)) + + return params + }, + assertResult: func(t *testing.T, params domain.ListServerSnapshotsParams, res domain.ListServerSnapshotsResult) { + t.Helper() + serverSnapshots := res.ServerSnapshots() + assert.NotEmpty(t, serverSnapshots) + assert.True(t, slices.IsSortedFunc(serverSnapshots, func(a, b domain.ServerSnapshot) int { + return cmp.Or( + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) * -1 + })) + assert.LessOrEqual(t, serverSnapshots[0].ID(), params.Cursor().ID()) + for _, ss := range serverSnapshots { + assert.LessOrEqual(t, ss.ServerKey(), params.Cursor().ServerKey()) + } + }, + }, + { + name: "OK: limit=2", + params: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetLimit(2)) + return params + }, + assertResult: func( + t *testing.T, + params domain.ListServerSnapshotsParams, + res domain.ListServerSnapshotsResult, + ) { + t.Helper() + assert.Len(t, res.ServerSnapshots(), params.Limit()) + assert.False(t, res.Self().IsZero()) + assert.False(t, res.Next().IsZero()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assertError := tt.assertError + if assertError == nil { + assertError = func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + } + } + + params := tt.params(t) + + res, err := repos.serverSnapshot.List(ctx, params) + assertError(t, err) + tt.assertResult(t, params, res) + }) + } + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortIDASC, + })) + + res, err := repos.serverSnapshot.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.ServerSnapshots()) + + randSnapshot := res.ServerSnapshots()[0] + + require.NoError(t, repos.serverSnapshot.Delete(ctx, randSnapshot.ServerKey(), randSnapshot.Date())) + + require.NoError(t, params.SetServerKeys([]string{randSnapshot.ServerKey()})) + + res, err = repos.serverSnapshot.List(ctx, params) + require.NoError(t, err) + assert.NotEmpty(t, res.ServerSnapshots()) + for _, ss := range res.ServerSnapshots() { + assert.True(t, ss.Date().After(randSnapshot.Date())) + } + }) + }) +} diff --git a/internal/adapter/repository_server_test.go b/internal/adapter/repository_server_test.go index 52e4f06..3e731ff 100644 --- a/internal/adapter/repository_server_test.go +++ b/internal/adapter/repository_server_test.go @@ -281,6 +281,26 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories } }, }, + { + name: "OK: snapshotCreatedAt=" + snapshotsCreatedAtLT.Format(time.RFC3339), + params: func(t *testing.T) domain.ListServersParams { + t.Helper() + params := domain.NewListServersParams() + require.NoError(t, params.SetSnapshotCreatedAtLT(domain.NullTime{ + V: snapshotsCreatedAtLT, + Valid: true, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListServersParams, res domain.ListServersResult) { + t.Helper() + servers := res.Servers() + assert.NotEmpty(t, servers) + for _, s := range servers { + assert.True(t, s.SnapshotCreatedAt().Before(snapshotsCreatedAtLT)) + } + }, + }, { name: "OK: playerSnapshotsCreatedAtLt=" + snapshotsCreatedAtLT.Format(time.RFC3339), params: func(t *testing.T) domain.ListServersParams { @@ -550,6 +570,10 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories V: time.Now(), Valid: true, })) + require.NoError(t, updateParams.SetSnapshotCreatedAt(domain.NullTime{ + V: time.Now(), + Valid: true, + })) require.NoError(t, updateParams.SetTribeSnapshotsCreatedAt(domain.NullTime{ V: time.Now(), Valid: true, @@ -606,6 +630,12 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories serverAfterUpdate.EnnoblementDataSyncedAt(), time.Minute, ) + assert.WithinDuration( + t, + updateParams.SnapshotCreatedAt().V, + serverAfterUpdate.SnapshotCreatedAt(), + time.Minute, + ) assert.WithinDuration( t, updateParams.TribeSnapshotsCreatedAt().V, diff --git a/internal/adapter/repository_test.go b/internal/adapter/repository_test.go index 6af7d51..641f7ac 100644 --- a/internal/adapter/repository_test.go +++ b/internal/adapter/repository_test.go @@ -67,6 +67,12 @@ type tribeChangeRepository interface { ) (domain.ListTribeChangesWithRelationsResult, error) } +type serverSnapshotRepository interface { + Create(ctx context.Context, params ...domain.CreateServerSnapshotParams) error + List(ctx context.Context, params domain.ListServerSnapshotsParams) (domain.ListServerSnapshotsResult, error) + Delete(ctx context.Context, serverKey string, dateLTE time.Time) error +} + type tribeSnapshotRepository interface { Create(ctx context.Context, params ...domain.CreateTribeSnapshotParams) error List(ctx context.Context, params domain.ListTribeSnapshotsParams) (domain.ListTribeSnapshotsResult, error) @@ -95,6 +101,7 @@ type repositories struct { village villageRepository ennoblement ennoblementRepository tribeChange tribeChangeRepository + serverSnapshot serverSnapshotRepository tribeSnapshot tribeSnapshotRepository playerSnapshot playerSnapshotRepository } @@ -112,6 +119,7 @@ func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { village: adapter.NewVillageBunRepository(bunDB), ennoblement: adapter.NewEnnoblementBunRepository(bunDB), tribeChange: adapter.NewTribeChangeBunRepository(bunDB), + serverSnapshot: adapter.NewServerSnapshotBunRepository(bunDB), tribeSnapshot: adapter.NewTribeSnapshotBunRepository(bunDB), playerSnapshot: adapter.NewPlayerSnapshotBunRepository(bunDB), } diff --git a/internal/adapter/repository_tribe_snapshot_test.go b/internal/adapter/repository_tribe_snapshot_test.go index 6bfecc5..02cbdf5 100644 --- a/internal/adapter/repository_tribe_snapshot_test.go +++ b/internal/adapter/repository_tribe_snapshot_test.go @@ -73,12 +73,14 @@ func testTribeSnapshotRepository(t *testing.T, newRepos func(t *testing.T) repos key := fmt.Sprintf("%s-%d-%s", p.ServerKey(), p.TribeID(), p.Date().Format(dateFormat)) for i, ts := range tribeSnapshots { - if ts.ServerKey() == p.ServerKey() && ts.TribeID() == p.TribeID() && ts.Date().Equal(p.Date()) { + //nolint:lll + if ts.ServerKey() == p.ServerKey() && ts.TribeID() == p.TribeID() && ts.Date().Format(dateFormat) == p.Date().Format(dateFormat) { m[key] = append(m[key], i) } } } + assert.NotEmpty(t, m) for key, indexes := range m { assert.Len(t, indexes, 1, key) } diff --git a/internal/adapter/testdata/fixture.yml b/internal/adapter/testdata/fixture.yml index 406fc8b..9380107 100644 --- a/internal/adapter/testdata/fixture.yml +++ b/internal/adapter/testdata/fixture.yml @@ -12,6 +12,7 @@ num_barbarian_villages: 1180 num_bonus_villages: 512 created_at: 2022-03-19T12:00:54.000Z + snapshot_created_at: 2022-03-19T12:00:54.000Z player_data_synced_at: 2022-03-19T12:00:54.000Z player_snapshots_created_at: 2022-03-19T12:00:54.000Z tribe_data_synced_at: 2022-03-19T12:00:54.000Z @@ -31,6 +32,7 @@ num_barbarian_villages: 700 num_bonus_villages: 1024 created_at: 2021-04-02T16:01:25.000Z + snapshot_created_at: 2021-04-02T16:01:25.000Z player_data_synced_at: 2021-04-02T16:01:25.000Z player_snapshots_created_at: 2021-04-02T16:01:25.000Z tribe_data_synced_at: 2021-04-02T16:01:25.000Z @@ -50,6 +52,7 @@ num_barbarian_villages: 1682 num_bonus_villages: 256 created_at: 2022-03-19T12:00:04.000Z + snapshot_created_at: 2022-03-19T12:00:04.000Z player_data_synced_at: 2022-03-19T12:00:04.000Z player_snapshots_created_at: 2022-03-19T12:00:04.000Z tribe_data_synced_at: 2022-03-19T12:00:04.000Z @@ -69,6 +72,7 @@ num_barbarian_villages: 1574 num_bonus_villages: 2048 created_at: 2022-03-19T12:01:39.000Z + snapshot_created_at: 2022-03-19T12:01:39.000Z player_data_synced_at: 2022-03-19T12:01:39.000Z player_snapshots_created_at: 2022-03-19T12:01:39.000Z tribe_data_synced_at: 2022-03-19T12:01:39.000Z @@ -7374,6 +7378,64 @@ new_tribe_id: 2 server_key: pl169 created_at: 2021-09-10T20:01:11.000Z +- model: ServerSnapshot + rows: + - id: 10000 + server_key: de188 + num_players: 0 + num_active_players: 180 + num_inactive_players: 0 + num_tribes: 0 + num_active_tribes: 76 + num_inactive_tribes: 0 + num_villages: 16180 + num_player_villages: 15000 + num_barbarian_villages: 1180 + num_bonus_villages: 512 + date: 2024-05-01T05:15:25.154992Z + created_at: 2024-05-01T05:15:25.154994Z + - id: 10001 + server_key: de188 + num_players: 0 + num_active_players: 180 + num_inactive_players: 0 + num_tribes: 0 + num_active_tribes: 76 + num_inactive_tribes: 0 + num_villages: 16180 + num_player_villages: 15000 + num_barbarian_villages: 1180 + num_bonus_villages: 512 + date: 2024-05-02T05:15:25.154992Z + created_at: 2024-05-02T05:15:25.154994Z + - id: 20000 + server_key: it70 + num_players: 0 + num_active_players: 180 + num_inactive_players: 0 + num_tribes: 0 + num_active_tribes: 76 + num_inactive_tribes: 0 + num_villages: 16180 + num_player_villages: 15000 + num_barbarian_villages: 1180 + num_bonus_villages: 512 + date: 2024-05-03T05:15:25.154992Z + created_at: 2024-05-03T05:15:25.154994Z + - id: 20001 + server_key: it70 + num_players: 0 + num_active_players: 180 + num_inactive_players: 0 + num_tribes: 0 + num_active_tribes: 76 + num_inactive_tribes: 0 + num_villages: 16180 + num_player_villages: 15000 + num_barbarian_villages: 1180 + num_bonus_villages: 512 + date: 2024-05-04T05:15:25.154992Z + created_at: 2024-05-04T05:15:25.154994Z - model: TribeSnapshot rows: - rank_att: 1 diff --git a/internal/app/service_server.go b/internal/app/service_server.go index 5a95788..17042c6 100644 --- a/internal/app/service_server.go +++ b/internal/app/service_server.go @@ -298,6 +298,21 @@ func (svc *ServerService) UpdateEnnoblementDataSyncedAt( return svc.repo.Update(ctx, key, updateParams) } +func (svc *ServerService) UpdateSnapshotCreatedAt( + ctx context.Context, + key string, +) error { + var updateParams domain.UpdateServerParams + if err := updateParams.SetSnapshotCreatedAt(domain.NullTime{ + V: time.Now(), + Valid: true, + }); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + + return svc.repo.Update(ctx, key, updateParams) +} + func (svc *ServerService) UpdateTribeSnapshotsCreatedAt( ctx context.Context, payload domain.SnapshotsCreatedEventPayload, diff --git a/internal/app/service_server_snapshot.go b/internal/app/service_server_snapshot.go new file mode 100644 index 0000000..ae96727 --- /dev/null +++ b/internal/app/service_server_snapshot.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + "fmt" + + "gitea.dwysokinski.me/twhelp/core/internal/domain" +) + +type ServerSnapshotRepository interface { + // Create persists tribe snapshots in a store (e.g. Postgres). + // Duplicates are ignored. + Create(ctx context.Context, params ...domain.CreateServerSnapshotParams) error +} + +type ServerSnapshotService struct { + repo ServerSnapshotRepository + serverSvc *ServerService + pub SnapshotPublisher +} + +func NewServerSnapshotService( + repo ServerSnapshotRepository, + serverSvc *ServerService, + pub SnapshotPublisher, +) *ServerSnapshotService { + return &ServerSnapshotService{repo: repo, serverSvc: serverSvc, pub: pub} +} + +//nolint:gocyclo +func (svc *ServerSnapshotService) Create( + ctx context.Context, + createSnapshotsCmdPayload domain.CreateSnapshotsCmdPayload, +) error { + versionCode := createSnapshotsCmdPayload.VersionCode() + serverKey := createSnapshotsCmdPayload.ServerKey() + date := createSnapshotsCmdPayload.Date() + + server, err := svc.serverSvc.GetNormalByVersionCodeAndServerKey(ctx, versionCode, serverKey) + if err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + if !server.Open() { + return nil + } + + params, err := domain.NewCreateServerSnapshotParams(server, 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 = svc.serverSvc.UpdateSnapshotCreatedAt(ctx, serverKey); err != nil { + return fmt.Errorf("%s: %w", serverKey, err) + } + + return nil +} diff --git a/internal/app/service_snapshot.go b/internal/app/service_snapshot.go index 6772c5b..00cec11 100644 --- a/internal/app/service_snapshot.go +++ b/internal/app/service_snapshot.go @@ -12,6 +12,7 @@ import ( type SnapshotService struct { versionSvc *VersionService serverSvc *ServerService + serverSnapshotPub SnapshotPublisher tribeSnapshotPub SnapshotPublisher playerSnapshotPub SnapshotPublisher } @@ -19,14 +20,16 @@ type SnapshotService struct { func NewSnapshotService( versionSvc *VersionService, serverSvc *ServerService, - tribeSnapshotPublisher SnapshotPublisher, - playerSnapshotPublisher SnapshotPublisher, + serverSnapshotPub SnapshotPublisher, + tribeSnapshotPub SnapshotPublisher, + playerSnapshotPub SnapshotPublisher, ) *SnapshotService { return &SnapshotService{ versionSvc: versionSvc, serverSvc: serverSvc, - tribeSnapshotPub: tribeSnapshotPublisher, - playerSnapshotPub: playerSnapshotPublisher, + serverSnapshotPub: serverSnapshotPub, + tribeSnapshotPub: tribeSnapshotPub, + playerSnapshotPub: playerSnapshotPub, } } @@ -48,6 +51,7 @@ func (svc *SnapshotService) Create(ctx context.Context) error { date := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) if loopErr = errors.Join( + svc.publishServer(ctx, v, snapshotsCreatedAtLT, date), svc.publishTribe(ctx, v, snapshotsCreatedAtLT, date), svc.publishPlayer(ctx, v, snapshotsCreatedAtLT, date), ); loopErr != nil { @@ -58,23 +62,47 @@ func (svc *SnapshotService) Create(ctx context.Context) error { return nil } +func (svc *SnapshotService) publishServer( + ctx context.Context, + v domain.Version, + snapshotCreatedAtLT time.Time, + date time.Time, +) error { + params, err := svc.baseParams(v) + if err != nil { + return err + } + if err = params.SetSnapshotCreatedAtLT(domain.NullTime{ + V: snapshotCreatedAtLT, + 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.serverSnapshotPub.CmdCreate(ctx, payloads...) +} + func (svc *SnapshotService) publishTribe( 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 { + params, err := svc.baseParams(v) + if err != nil { return err } - if err := params.SetOpen(domain.NullBool{ - V: true, - Valid: true, - }); err != nil { - return err - } - if err := params.SetTribeSnapshotsCreatedAtLT(domain.NullTime{ + if err = params.SetTribeSnapshotsCreatedAtLT(domain.NullTime{ V: snapshotsCreatedAtLT, Valid: true, }); err != nil { @@ -100,17 +128,11 @@ func (svc *SnapshotService) publishPlayer( snapshotsCreatedAtLT time.Time, date time.Time, ) error { - params := domain.NewListServersParams() - if err := params.SetVersionCodes([]string{v.Code()}); err != nil { + params, err := svc.baseParams(v) + if err != nil { return err } - if err := params.SetOpen(domain.NullBool{ - V: true, - Valid: true, - }); err != nil { - return err - } - if err := params.SetPlayerSnapshotsCreatedAtLT(domain.NullTime{ + if err = params.SetPlayerSnapshotsCreatedAtLT(domain.NullTime{ V: snapshotsCreatedAtLT, Valid: true, }); err != nil { @@ -130,6 +152,20 @@ func (svc *SnapshotService) publishPlayer( return svc.playerSnapshotPub.CmdCreate(ctx, payloads...) } +func (svc *SnapshotService) baseParams(v domain.Version) (domain.ListServersParams, error) { + params := domain.NewListServersParams() + if err := params.SetVersionCodes([]string{v.Code()}); err != nil { + return domain.ListServersParams{}, err + } + if err := params.SetOpen(domain.NullBool{ + V: true, + Valid: true, + }); err != nil { + return domain.ListServersParams{}, nil + } + return params, nil +} + func (svc *SnapshotService) toPayload( v domain.Version, servers domain.Servers, diff --git a/internal/bun/bunmodel/server.go b/internal/bun/bunmodel/server.go index 5c4ae8c..2f4efa6 100644 --- a/internal/bun/bunmodel/server.go +++ b/internal/bun/bunmodel/server.go @@ -30,6 +30,7 @@ type Server struct { BuildingInfo BuildingInfo `bun:"building_info"` UnitInfo UnitInfo `bun:"unit_info"` CreatedAt time.Time `bun:"created_at,nullzero"` + SnapshotCreatedAt time.Time `bun:"snapshot_created_at,nullzero"` PlayerDataUpdatedAt time.Time `bun:"player_data_synced_at,nullzero"` PlayerSnapshotsCreatedAt time.Time `bun:"player_snapshots_created_at,nullzero"` TribeDataUpdatedAt time.Time `bun:"tribe_data_synced_at,nullzero"` @@ -75,6 +76,7 @@ func (s Server) ToDomain() (domain.Server, error) { buildingInfo, unitInfo, s.CreatedAt, + s.SnapshotCreatedAt, s.PlayerDataUpdatedAt, s.PlayerSnapshotsCreatedAt, s.TribeDataUpdatedAt, diff --git a/internal/bun/bunmodel/server_snapshot.go b/internal/bun/bunmodel/server_snapshot.go new file mode 100644 index 0000000..c1b98da --- /dev/null +++ b/internal/bun/bunmodel/server_snapshot.go @@ -0,0 +1,62 @@ +package bunmodel + +import ( + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/core/internal/domain" + "github.com/uptrace/bun" +) + +type ServerSnapshot struct { + bun.BaseModel `bun:"table:server_snapshots,alias:ss"` + + ID int `bun:"id,pk,autoincrement,identity"` + ServerKey string `bun:"server_key,nullzero"` + NumPlayers int `bun:"num_players"` + NumActivePlayers int `bun:"num_active_players"` + NumInactivePlayers int `bun:"num_inactive_players"` + NumTribes int `bun:"num_tribes"` + NumActiveTribes int `bun:"num_active_tribes"` + NumInactiveTribes int `bun:"num_inactive_tribes"` + NumVillages int `bun:"num_villages"` + NumPlayerVillages int `bun:"num_player_villages"` + NumBarbarianVillages int `bun:"num_barbarian_villages"` + NumBonusVillages int `bun:"num_bonus_villages"` + Date time.Time `bun:"date,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero"` +} + +func (ss ServerSnapshot) ToDomain() (domain.ServerSnapshot, error) { + converted, err := domain.UnmarshalServerSnapshotFromDatabase( + ss.ID, + ss.ServerKey, + ss.NumPlayers, + ss.NumActivePlayers, + ss.NumInactivePlayers, + ss.NumTribes, + ss.NumActiveTribes, + ss.NumInactiveTribes, + ss.NumVillages, + ss.NumPlayerVillages, + ss.NumBarbarianVillages, + ss.NumBonusVillages, + ss.Date, + ss.CreatedAt, + ) + if err != nil { + return domain.ServerSnapshot{}, fmt.Errorf( + "couldn't construct domain.ServerSnapshot (id=%d): %w", + ss.ID, + err, + ) + } + + return converted, nil +} + +type ServerSnapshots []ServerSnapshot + +func (sss ServerSnapshots) ToDomain() (domain.ServerSnapshots, error) { + return sliceToDomain(sss) +} diff --git a/internal/bun/buntest/fixture.go b/internal/bun/buntest/fixture.go index 729cce9..e234343 100644 --- a/internal/bun/buntest/fixture.go +++ b/internal/bun/buntest/fixture.go @@ -23,6 +23,7 @@ func NewFixture(bunDB *bun.DB) *Fixture { (*bunmodel.Village)(nil), (*bunmodel.Ennoblement)(nil), (*bunmodel.TribeChange)(nil), + (*bunmodel.ServerSnapshot)(nil), (*bunmodel.TribeSnapshot)(nil), (*bunmodel.PlayerSnapshot)(nil), ) diff --git a/internal/bun/migrations/20240506053957_servers_fill_num_columns.go b/internal/bun/migrations/20240506053957_servers_fill_num_columns.go index 31d6d84..431fcdc 100644 --- a/internal/bun/migrations/20240506053957_servers_fill_num_columns.go +++ b/internal/bun/migrations/20240506053957_servers_fill_num_columns.go @@ -12,7 +12,11 @@ func init() { migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { var servers bunmodel.Servers - if err := db.NewSelect().Model(&servers).Where("special = false").Scan(ctx); err != nil { + if err := db.NewSelect(). + Model(&servers). + ExcludeColumn("snapshot_created_at"). + Where("special = false"). + Scan(ctx); err != nil { return fmt.Errorf("couldn't select servers from the db: %w", err) } diff --git a/internal/bun/migrations/20240508043508_servers_add_snapshot_created_at_column.go b/internal/bun/migrations/20240508043508_servers_add_snapshot_created_at_column.go new file mode 100644 index 0000000..96d7225 --- /dev/null +++ b/internal/bun/migrations/20240508043508_servers_add_snapshot_created_at_column.go @@ -0,0 +1,17 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" +) + +func init() { + migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "ALTER TABLE servers ADD snapshot_created_at timestamp with time zone") + return err + }, func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "ALTER TABLE servers DROP COLUMN snapshot_created_at") + return err + }) +} diff --git a/internal/bun/migrations/20240508051845_create_server_snapshots_table.go b/internal/bun/migrations/20240508051845_create_server_snapshots_table.go new file mode 100644 index 0000000..9828056 --- /dev/null +++ b/internal/bun/migrations/20240508051845_create_server_snapshots_table.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" +) + +func init() { + migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, ` +create table if not exists server_snapshots +( + ?ID_COL, + server_key varchar(100) not null + references servers, + date date not null, + created_at timestamp with time zone default CURRENT_TIMESTAMP not null, + num_players bigint default 0, + num_active_players bigint default 0, + num_inactive_players bigint default 0, + num_tribes bigint default 0, + num_active_tribes bigint default 0, + num_inactive_tribes bigint default 0, + num_villages bigint default 0, + num_player_villages bigint default 0, + num_barbarian_villages bigint default 0, + num_bonus_villages bigint default 0, + unique (server_key, date) +); +`) + return err + }, func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "drop table if exists server_snapshots cascade;") + return err + }) +} diff --git a/internal/domain/domaintest/server.go b/internal/domain/domaintest/server.go index 7f90961..bda20de 100644 --- a/internal/domain/domaintest/server.go +++ b/internal/domain/domaintest/server.go @@ -97,6 +97,7 @@ func NewServer(tb TestingTB, opts ...func(cfg *ServerConfig)) domain.Server { NewBuildingInfo(tb), NewUnitInfo(tb), time.Now(), + time.Now(), cfg.PlayerDataSyncedAt, cfg.PlayerSnapshotsCreatedAt, cfg.TribeDataSyncedAt, diff --git a/internal/domain/domaintest/server_snapshot.go b/internal/domain/domaintest/server_snapshot.go new file mode 100644 index 0000000..99cc606 --- /dev/null +++ b/internal/domain/domaintest/server_snapshot.go @@ -0,0 +1,102 @@ +package domaintest + +import ( + "time" + + "gitea.dwysokinski.me/twhelp/core/internal/domain" + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/require" +) + +type ServerSnapshotCursorConfig struct { + ID int + ServerKey string + Date time.Time +} + +func NewServerSnapshotCursor(tb TestingTB, opts ...func(cfg *ServerSnapshotCursorConfig)) domain.ServerSnapshotCursor { + tb.Helper() + + cfg := &ServerSnapshotCursorConfig{ + ID: RandID(), + ServerKey: RandServerKey(), + Date: gofakeit.Date(), + } + + for _, opt := range opts { + opt(cfg) + } + + ssc, err := domain.NewServerSnapshotCursor( + cfg.ID, + cfg.ServerKey, + cfg.Date, + ) + require.NoError(tb, err) + + return ssc +} + +type ServerSnapshotConfig struct { + ID int + ServerKey string + NumPlayers int + NumActivePlayers int + NumInactivePlayers int + NumTribes int + NumActiveTribes int + NumInactiveTribes int + NumVillages int + NumPlayerVillages int + NumBarbarianVillages int + NumBonusVillages int + Date time.Time + CreatedAt time.Time +} + +func NewServerSnapshot(tb TestingTB, opts ...func(cfg *ServerSnapshotConfig)) domain.ServerSnapshot { + tb.Helper() + + now := time.Now() + + cfg := &ServerSnapshotConfig{ + ID: RandID(), + ServerKey: RandServerKey(), + NumPlayers: gofakeit.IntRange(1, 10000), + NumActivePlayers: gofakeit.IntRange(1, 10000), + NumInactivePlayers: gofakeit.IntRange(1, 10000), + NumTribes: gofakeit.IntRange(1, 10000), + NumActiveTribes: gofakeit.IntRange(1, 10000), + NumInactiveTribes: gofakeit.IntRange(1, 10000), + NumVillages: gofakeit.IntRange(1, 10000), + NumPlayerVillages: gofakeit.IntRange(1, 10000), + NumBarbarianVillages: gofakeit.IntRange(1, 10000), + NumBonusVillages: gofakeit.IntRange(1, 10000), + Date: now, + CreatedAt: now, + } + + for _, opt := range opts { + opt(cfg) + } + + ss, err := domain.UnmarshalServerSnapshotFromDatabase( + cfg.ID, + cfg.ServerKey, + cfg.NumPlayers, + cfg.NumActivePlayers, + cfg.NumInactivePlayers, + cfg.NumTribes, + cfg.NumActiveTribes, + cfg.NumInactiveTribes, + cfg.NumVillages, + cfg.NumPlayerVillages, + cfg.NumBarbarianVillages, + cfg.NumBonusVillages, + cfg.Date, + cfg.CreatedAt, + ) + require.NoError(tb, err) + + return ss +} diff --git a/internal/domain/domaintest/tribe_snapshot.go b/internal/domain/domaintest/tribe_snapshot.go index 9d32738..b6b1655 100644 --- a/internal/domain/domaintest/tribe_snapshot.go +++ b/internal/domain/domaintest/tribe_snapshot.go @@ -27,14 +27,14 @@ func NewTribeSnapshotCursor(tb TestingTB, opts ...func(cfg *TribeSnapshotCursorC opt(cfg) } - psc, err := domain.NewTribeSnapshotCursor( + tsc, err := domain.NewTribeSnapshotCursor( cfg.ID, cfg.ServerKey, cfg.Date, ) require.NoError(tb, err) - return psc + return tsc } type TribeSnapshotConfig struct { diff --git a/internal/domain/server.go b/internal/domain/server.go index acf93f8..17daad3 100644 --- a/internal/domain/server.go +++ b/internal/domain/server.go @@ -28,6 +28,7 @@ type Server struct { buildingInfo BuildingInfo unitInfo UnitInfo createdAt time.Time + snapshotCreatedAt time.Time playerDataSyncedAt time.Time playerSnapshotsCreatedAt time.Time tribeDataSyncedAt time.Time @@ -62,6 +63,7 @@ func UnmarshalServerFromDatabase( buildingInfo BuildingInfo, unitInfo UnitInfo, createdAt time.Time, + snapshotCreatedAt time.Time, playerDataSyncedAt time.Time, playerSnapshotsCreatedAt time.Time, tribeDataSyncedAt time.Time, @@ -114,6 +116,7 @@ func UnmarshalServerFromDatabase( buildingInfo: buildingInfo, unitInfo: unitInfo, createdAt: createdAt, + snapshotCreatedAt: snapshotCreatedAt, playerDataSyncedAt: playerDataSyncedAt, playerSnapshotsCreatedAt: playerSnapshotsCreatedAt, tribeDataSyncedAt: tribeDataSyncedAt, @@ -199,6 +202,10 @@ func (s Server) CreatedAt() time.Time { return s.createdAt } +func (s Server) SnapshotCreatedAt() time.Time { + return s.snapshotCreatedAt +} + func (s Server) PlayerDataSyncedAt() time.Time { return s.playerDataSyncedAt } @@ -419,6 +426,7 @@ type UpdateServerParams struct { numBonusVillages NullInt villageDataSyncedAt NullTime ennoblementDataSyncedAt NullTime + snapshotCreatedAt NullTime tribeSnapshotsCreatedAt NullTime playerSnapshotsCreatedAt NullTime } @@ -688,6 +696,15 @@ func (params *UpdateServerParams) SetEnnoblementDataSyncedAt(ennoblementDataSync return nil } +func (params *UpdateServerParams) SnapshotCreatedAt() NullTime { + return params.snapshotCreatedAt +} + +func (params *UpdateServerParams) SetSnapshotCreatedAt(snapshotCreatedAt NullTime) error { + params.snapshotCreatedAt = snapshotCreatedAt + return nil +} + func (params *UpdateServerParams) TribeSnapshotsCreatedAt() NullTime { return params.tribeSnapshotsCreatedAt } @@ -721,6 +738,7 @@ func (params *UpdateServerParams) IsZero() bool { !params.numBonusVillages.Valid && !params.villageDataSyncedAt.Valid && !params.ennoblementDataSyncedAt.Valid && + !params.snapshotCreatedAt.Valid && !params.tribeSnapshotsCreatedAt.Valid && !params.playerSnapshotsCreatedAt.Valid } @@ -825,6 +843,7 @@ type ListServersParams struct { versionCodes []string open NullBool special NullBool + snapshotCreatedAtLT NullTime tribeSnapshotsCreatedAtLT NullTime playerSnapshotsCreatedAtLT NullTime sort []ServerSort @@ -908,6 +927,15 @@ func (params *ListServersParams) SetSpecial(special NullBool) error { return nil } +func (params *ListServersParams) SnapshotCreatedAtLT() NullTime { + return params.snapshotCreatedAtLT +} + +func (params *ListServersParams) SetSnapshotCreatedAtLT(snapshotCreatedAtLT NullTime) error { + params.snapshotCreatedAtLT = snapshotCreatedAtLT + return nil +} + func (params *ListServersParams) TribeSnapshotsCreatedAtLT() NullTime { return params.tribeSnapshotsCreatedAtLT } diff --git a/internal/domain/server_snapshot.go b/internal/domain/server_snapshot.go new file mode 100644 index 0000000..94c3817 --- /dev/null +++ b/internal/domain/server_snapshot.go @@ -0,0 +1,578 @@ +package domain + +import ( + "errors" + "math" + "time" +) + +type ServerSnapshot struct { + id int + serverKey string + numPlayers int + numActivePlayers int + numInactivePlayers int + numTribes int + numActiveTribes int + numInactiveTribes int + numVillages int + numPlayerVillages int + numBarbarianVillages int + numBonusVillages int + date time.Time + createdAt time.Time +} + +const serverSnapshotModelName = "ServerSnapshot" + +// UnmarshalServerSnapshotFromDatabase unmarshals ServerSnapshot from the database. +// +// It should be used only for unmarshalling from the database! +// You can't use UnmarshalServerSnapshotFromDatabase as constructor - It may put domain into the invalid state! +func UnmarshalServerSnapshotFromDatabase( + id int, + serverKey string, + numPlayers int, + numActivePlayers int, + numInactivePlayers int, + numTribes int, + numActiveTribes int, + numInactiveTribes int, + numVillages int, + numPlayerVillages int, + numBarbarianVillages int, + numBonusVillages int, + date time.Time, + createdAt time.Time, +) (ServerSnapshot, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return ServerSnapshot{}, ValidationError{ + Model: serverSnapshotModelName, + Field: "id", + Err: err, + } + } + + if err := validateServerKey(serverKey); err != nil { + return ServerSnapshot{}, ValidationError{ + Model: serverSnapshotModelName, + Field: "serverKey", + Err: err, + } + } + + return ServerSnapshot{ + id: id, + serverKey: serverKey, + numPlayers: numPlayers, + numActivePlayers: numActivePlayers, + numInactivePlayers: numInactivePlayers, + numTribes: numTribes, + numActiveTribes: numActiveTribes, + numInactiveTribes: numInactiveTribes, + numVillages: numVillages, + numPlayerVillages: numPlayerVillages, + numBarbarianVillages: numBarbarianVillages, + numBonusVillages: numBonusVillages, + date: date, + createdAt: createdAt, + }, nil +} + +func (ss ServerSnapshot) ID() int { + return ss.id +} + +func (ss ServerSnapshot) ServerKey() string { + return ss.serverKey +} + +func (ss ServerSnapshot) NumPlayers() int { + return ss.numPlayers +} + +func (ss ServerSnapshot) NumActivePlayers() int { + return ss.numActivePlayers +} + +func (ss ServerSnapshot) NumInactivePlayers() int { + return ss.numInactivePlayers +} + +func (ss ServerSnapshot) NumTribes() int { + return ss.numTribes +} + +func (ss ServerSnapshot) NumActiveTribes() int { + return ss.numActiveTribes +} + +func (ss ServerSnapshot) NumInactiveTribes() int { + return ss.numInactiveTribes +} + +func (ss ServerSnapshot) NumVillages() int { + return ss.numVillages +} + +func (ss ServerSnapshot) NumPlayerVillages() int { + return ss.numPlayerVillages +} + +func (ss ServerSnapshot) NumBarbarianVillages() int { + return ss.numBarbarianVillages +} + +func (ss ServerSnapshot) NumBonusVillages() int { + return ss.numBonusVillages +} + +func (ss ServerSnapshot) Date() time.Time { + return ss.date +} + +func (ss ServerSnapshot) CreatedAt() time.Time { + return ss.createdAt +} + +func (ss ServerSnapshot) ToCursor() (ServerSnapshotCursor, error) { + return NewServerSnapshotCursor(ss.id, ss.serverKey, ss.date) +} + +func (ss ServerSnapshot) IsZero() bool { + return ss == ServerSnapshot{} +} + +type ServerSnapshots []ServerSnapshot + +type CreateServerSnapshotParams struct { + serverKey string + numPlayers int + numActivePlayers int + numInactivePlayers int + numTribes int + numActiveTribes int + numInactiveTribes int + numVillages int + numPlayerVillages int + numBarbarianVillages int + numBonusVillages int + date time.Time +} + +func NewCreateServerSnapshotParams(server Server, date time.Time) (CreateServerSnapshotParams, error) { + if server.IsZero() { + return CreateServerSnapshotParams{}, errors.New("given server is an empty struct") + } + + if !server.Open() { + return CreateServerSnapshotParams{}, errors.New("given server is closed") + } + + return CreateServerSnapshotParams{ + serverKey: server.Key(), + numPlayers: server.NumPlayers(), + numActivePlayers: server.NumActivePlayers(), + numInactivePlayers: server.NumInactivePlayers(), + numTribes: server.NumTribes(), + numActiveTribes: server.NumActiveTribes(), + numInactiveTribes: server.NumInactivePlayers(), + numVillages: server.NumVillages(), + numPlayerVillages: server.NumPlayerVillages(), + numBarbarianVillages: server.NumBarbarianVillages(), + numBonusVillages: server.NumBonusVillages(), + date: date, + }, nil +} + +func (params CreateServerSnapshotParams) ServerKey() string { + return params.serverKey +} + +func (params CreateServerSnapshotParams) NumPlayers() int { + return params.numPlayers +} + +func (params CreateServerSnapshotParams) NumActivePlayers() int { + return params.numActivePlayers +} + +func (params CreateServerSnapshotParams) NumInactivePlayers() int { + return params.numInactivePlayers +} + +func (params CreateServerSnapshotParams) NumTribes() int { + return params.numTribes +} + +func (params CreateServerSnapshotParams) NumActiveTribes() int { + return params.numActiveTribes +} + +func (params CreateServerSnapshotParams) NumInactiveTribes() int { + return params.numInactiveTribes +} + +func (params CreateServerSnapshotParams) NumVillages() int { + return params.numVillages +} + +func (params CreateServerSnapshotParams) NumPlayerVillages() int { + return params.numPlayerVillages +} + +func (params CreateServerSnapshotParams) NumBarbarianVillages() int { + return params.numBarbarianVillages +} + +func (params CreateServerSnapshotParams) NumBonusVillages() int { + return params.numBonusVillages +} + +func (params CreateServerSnapshotParams) Date() time.Time { + return params.date +} + +type ServerSnapshotSort uint8 + +const ( + ServerSnapshotSortDateASC ServerSnapshotSort = iota + 1 + ServerSnapshotSortDateDESC + ServerSnapshotSortIDASC + ServerSnapshotSortIDDESC + ServerSnapshotSortServerKeyASC + ServerSnapshotSortServerKeyDESC +) + +// IsInConflict returns true if two sorts can't be used together +// (e.g. ServerSnapshotSortIDASC and ServerSnapshotSortIDDESC). +func (s ServerSnapshotSort) IsInConflict(s2 ServerSnapshotSort) bool { + return isSortInConflict(s, s2) +} + +//nolint:gocyclo +func (s ServerSnapshotSort) String() string { + switch s { + case ServerSnapshotSortDateASC: + return "date:ASC" + case ServerSnapshotSortDateDESC: + return "date:DESC" + case ServerSnapshotSortIDASC: + return "id:ASC" + case ServerSnapshotSortIDDESC: + return "id:DESC" + case ServerSnapshotSortServerKeyASC: + return "serverKey:ASC" + case ServerSnapshotSortServerKeyDESC: + return "serverKey:DESC" + default: + return "unknown server snapshot sort" + } +} + +type ServerSnapshotCursor struct { + id int + serverKey string + date time.Time +} + +const serverSnapshotCursorModelName = "ServerSnapshotCursor" + +func NewServerSnapshotCursor(id int, serverKey string, date time.Time) (ServerSnapshotCursor, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return ServerSnapshotCursor{}, ValidationError{ + Model: serverSnapshotCursorModelName, + Field: "id", + Err: err, + } + } + + if err := validateServerKey(serverKey); err != nil { + return ServerSnapshotCursor{}, ValidationError{ + Model: serverSnapshotCursorModelName, + Field: "serverKey", + Err: err, + } + } + + return ServerSnapshotCursor{ + id: id, + serverKey: serverKey, + date: date, + }, nil +} + +func decodeServerSnapshotCursor(encoded string) (ServerSnapshotCursor, error) { + m, err := decodeCursor(encoded) + if err != nil { + return ServerSnapshotCursor{}, err + } + + id, err := m.int("id") + if err != nil { + return ServerSnapshotCursor{}, ErrInvalidCursor + } + + serverKey, err := m.string("serverKey") + if err != nil { + return ServerSnapshotCursor{}, ErrInvalidCursor + } + + date, err := m.time("date") + if err != nil { + return ServerSnapshotCursor{}, ErrInvalidCursor + } + + tsc, err := NewServerSnapshotCursor( + id, + serverKey, + date, + ) + if err != nil { + return ServerSnapshotCursor{}, ErrInvalidCursor + } + + return tsc, nil +} + +func (ssc ServerSnapshotCursor) ID() int { + return ssc.id +} + +func (ssc ServerSnapshotCursor) ServerKey() string { + return ssc.serverKey +} + +func (ssc ServerSnapshotCursor) Date() time.Time { + return ssc.date +} + +func (ssc ServerSnapshotCursor) IsZero() bool { + return ssc == ServerSnapshotCursor{} +} + +func (ssc ServerSnapshotCursor) Encode() string { + if ssc.IsZero() { + return "" + } + + return encodeCursor([]keyValuePair{ + {"id", ssc.id}, + {"serverKey", ssc.serverKey}, + {"date", ssc.date}, + }) +} + +type ListServerSnapshotsParams struct { + serverKeys []string + sort []ServerSnapshotSort + cursor ServerSnapshotCursor + limit int +} + +const ( + ServerSnapshotListMaxLimit = 500 + listServerSnapshotsParamsModelName = "ListServerSnapshotsParams" +) + +func NewListServerSnapshotsParams() ListServerSnapshotsParams { + return ListServerSnapshotsParams{ + sort: []ServerSnapshotSort{ + ServerSnapshotSortServerKeyASC, + ServerSnapshotSortDateASC, + ServerSnapshotSortIDASC, + }, + limit: ServerSnapshotListMaxLimit, + } +} + +func (params *ListServerSnapshotsParams) ServerKeys() []string { + return params.serverKeys +} + +func (params *ListServerSnapshotsParams) SetServerKeys(serverKeys []string) error { + for i, sk := range serverKeys { + if err := validateServerKey(sk); err != nil { + return SliceElementValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "serverKeys", + Index: i, + Err: err, + } + } + } + + params.serverKeys = serverKeys + + return nil +} + +func (params *ListServerSnapshotsParams) Sort() []ServerSnapshotSort { + return params.sort +} + +const ( + serverSnapshotSortMinLength = 0 + serverSnapshotSortMaxLength = 3 +) + +func (params *ListServerSnapshotsParams) SetSort(sort []ServerSnapshotSort) error { + if err := validateSort(sort, serverSnapshotSortMinLength, serverSnapshotSortMaxLength); err != nil { + return ValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "sort", + Err: err, + } + } + + params.sort = sort + + return nil +} + +func (params *ListServerSnapshotsParams) PrependSort(sort []ServerSnapshotSort) error { + if len(sort) == 0 { + return nil + } + + if err := validateSliceLen(sort, 0, max(serverSnapshotSortMaxLength-len(params.sort), 0)); err != nil { + return ValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "sort", + Err: err, + } + } + + return params.SetSort(append(sort, params.sort...)) +} + +func (params *ListServerSnapshotsParams) PrependSortString( + sort []string, + allowed []ServerSnapshotSort, + maxLength int, +) error { + if len(sort) == 0 { + return nil + } + + if err := validateSliceLen(sort, 0, max(min(serverSnapshotSortMaxLength-len(params.sort), maxLength), 0)); err != nil { + return ValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "sort", + Err: err, + } + } + + toPrepend := make([]ServerSnapshotSort, 0, len(sort)) + + for i, s := range sort { + converted, err := newSortFromString(s, allowed...) + if err != nil { + return SliceElementValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "sort", + Index: i, + Err: err, + } + } + toPrepend = append(toPrepend, converted) + } + + return params.SetSort(append(toPrepend, params.sort...)) +} + +func (params *ListServerSnapshotsParams) Cursor() ServerSnapshotCursor { + return params.cursor +} + +func (params *ListServerSnapshotsParams) SetCursor(cursor ServerSnapshotCursor) error { + params.cursor = cursor + return nil +} + +func (params *ListServerSnapshotsParams) SetEncodedCursor(encoded string) error { + decoded, err := decodeServerSnapshotCursor(encoded) + if err != nil { + return ValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "cursor", + Err: err, + } + } + + params.cursor = decoded + + return nil +} + +func (params *ListServerSnapshotsParams) Limit() int { + return params.limit +} + +func (params *ListServerSnapshotsParams) SetLimit(limit int) error { + if err := validateIntInRange(limit, 1, ServerSnapshotListMaxLimit); err != nil { + return ValidationError{ + Model: listServerSnapshotsParamsModelName, + Field: "limit", + Err: err, + } + } + + params.limit = limit + + return nil +} + +type ListServerSnapshotsResult struct { + snapshots ServerSnapshots + self ServerSnapshotCursor + next ServerSnapshotCursor +} + +const listServerSnapshotsResultModelName = "ListServerSnapshotsResult" + +func NewListServerSnapshotsResult( + snapshots ServerSnapshots, + next ServerSnapshot, +) (ListServerSnapshotsResult, error) { + var err error + res := ListServerSnapshotsResult{ + snapshots: snapshots, + } + + if len(snapshots) > 0 { + res.self, err = snapshots[0].ToCursor() + if err != nil { + return ListServerSnapshotsResult{}, ValidationError{ + Model: listServerSnapshotsResultModelName, + Field: "self", + Err: err, + } + } + } + + if !next.IsZero() { + res.next, err = next.ToCursor() + if err != nil { + return ListServerSnapshotsResult{}, ValidationError{ + Model: listServerSnapshotsResultModelName, + Field: "next", + Err: err, + } + } + } + + return res, nil +} + +func (res ListServerSnapshotsResult) ServerSnapshots() ServerSnapshots { + return res.snapshots +} + +func (res ListServerSnapshotsResult) Self() ServerSnapshotCursor { + return res.self +} + +func (res ListServerSnapshotsResult) Next() ServerSnapshotCursor { + return res.next +} diff --git a/internal/domain/server_snapshot_test.go b/internal/domain/server_snapshot_test.go new file mode 100644 index 0000000..8be77f1 --- /dev/null +++ b/internal/domain/server_snapshot_test.go @@ -0,0 +1,854 @@ +package domain_test + +import ( + "fmt" + "testing" + "time" + + "gitea.dwysokinski.me/twhelp/core/internal/domain" + "gitea.dwysokinski.me/twhelp/core/internal/domain/domaintest" + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCreateServerSnapshotParams(t *testing.T) { + t.Parallel() + + server := domaintest.NewServer(t) + date := time.Now() + + params, err := domain.NewCreateServerSnapshotParams(server, date) + require.NoError(t, err) + assert.Equal(t, server.Key(), params.ServerKey()) + assert.Equal(t, server.NumPlayers(), params.NumPlayers()) + assert.Equal(t, server.NumActivePlayers(), params.NumActivePlayers()) + assert.Equal(t, server.NumInactivePlayers(), params.NumInactivePlayers()) + assert.Equal(t, server.NumTribes(), params.NumTribes()) + assert.Equal(t, server.NumActiveTribes(), params.NumActiveTribes()) + assert.Equal(t, server.NumInactiveTribes(), params.NumInactiveTribes()) + assert.Equal(t, server.NumVillages(), params.NumVillages()) + assert.Equal(t, server.NumPlayerVillages(), params.NumPlayerVillages()) + assert.Equal(t, server.NumBarbarianVillages(), params.NumBarbarianVillages()) + assert.Equal(t, server.NumBonusVillages(), params.NumBonusVillages()) + assert.Equal(t, date, params.Date()) +} + +func TestServerSnapshotSort_IsInConflict(t *testing.T) { + t.Parallel() + + type args struct { + sorts [2]domain.ServerSnapshotSort + } + + tests := []struct { + name string + args args + expectedRes bool + }{ + { + name: "OK: id:ASC serverKey:ASC", + args: args{ + sorts: [2]domain.ServerSnapshotSort{domain.ServerSnapshotSortIDASC, domain.ServerSnapshotSortServerKeyASC}, + }, + expectedRes: false, + }, + { + name: "OK: id:DESC serverKey:ASC", + args: args{ + sorts: [2]domain.ServerSnapshotSort{domain.ServerSnapshotSortIDDESC, domain.ServerSnapshotSortServerKeyASC}, + }, + expectedRes: false, + }, + { + name: "OK: id:ASC id:ASC", + args: args{ + sorts: [2]domain.ServerSnapshotSort{domain.ServerSnapshotSortIDASC, domain.ServerSnapshotSortIDASC}, + }, + expectedRes: true, + }, + { + name: "OK: id:ASC id:DESC", + args: args{ + sorts: [2]domain.ServerSnapshotSort{domain.ServerSnapshotSortIDASC, domain.ServerSnapshotSortIDDESC}, + }, + expectedRes: true, + }, + { + name: "OK: date:ASC date:DESC", + args: args{ + sorts: [2]domain.ServerSnapshotSort{domain.ServerSnapshotSortDateASC, domain.ServerSnapshotSortDateDESC}, + }, + expectedRes: true, + }, + { + name: "OK: serverKey:DESC serverKey:ASC", + args: args{ + sorts: [2]domain.ServerSnapshotSort{domain.ServerSnapshotSortServerKeyDESC, domain.ServerSnapshotSortServerKeyASC}, + }, + expectedRes: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expectedRes, tt.args.sorts[0].IsInConflict(tt.args.sorts[1])) + }) + } +} + +func TestNewServerSnapshotCursor(t *testing.T) { + t.Parallel() + + validServerSnapshotCursor := domaintest.NewServerSnapshotCursor(t) + + type args struct { + id int + serverKey string + date time.Time + } + + type test struct { + name string + args args + expectedErr error + } + + tests := []test{ + { + name: "OK", + args: args{ + id: validServerSnapshotCursor.ID(), + serverKey: validServerSnapshotCursor.ServerKey(), + date: validServerSnapshotCursor.Date(), + }, + expectedErr: nil, + }, + { + name: "ERR: id < 1", + args: args{ + id: 0, + serverKey: validServerSnapshotCursor.ServerKey(), + date: validServerSnapshotCursor.Date(), + }, + expectedErr: domain.ValidationError{ + Model: "ServerSnapshotCursor", + Field: "id", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + } + + for _, serverKeyTest := range newServerKeyValidationTests() { + tests = append(tests, test{ + name: serverKeyTest.name, + args: args{ + id: validServerSnapshotCursor.ID(), + serverKey: serverKeyTest.key, + }, + expectedErr: domain.ValidationError{ + Model: "ServerSnapshotCursor", + Field: "serverKey", + Err: serverKeyTest.expectedErr, + }, + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ssc, err := domain.NewServerSnapshotCursor( + tt.args.id, + tt.args.serverKey, + tt.args.date, + ) + require.ErrorIs(t, err, tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.id, ssc.ID()) + assert.Equal(t, tt.args.serverKey, ssc.ServerKey()) + assert.Equal(t, tt.args.date, ssc.Date()) + assert.NotEmpty(t, ssc.Encode()) + }) + } +} + +func TestListServerSnapshotsParams_SetServerKeys(t *testing.T) { + t.Parallel() + + type args struct { + serverKeys []string + } + + type test struct { + name string + args args + expectedErr error + } + + tests := []test{ + { + name: "OK", + args: args{ + serverKeys: []string{ + domaintest.RandServerKey(), + }, + }, + }, + } + + for _, serverKeyTest := range newServerKeyValidationTests() { + tests = append(tests, test{ + name: serverKeyTest.name, + args: args{ + serverKeys: []string{serverKeyTest.key}, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListServerSnapshotsParams", + Field: "serverKeys", + Index: 0, + Err: serverKeyTest.expectedErr, + }, + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListServerSnapshotsParams() + + require.ErrorIs(t, params.SetServerKeys(tt.args.serverKeys), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.serverKeys, params.ServerKeys()) + }) + } +} + +func TestListServerSnapshotsParams_SetSort(t *testing.T) { + t.Parallel() + + type args struct { + sort []domain.ServerSnapshotSort + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortServerKeyASC, + }, + }, + }, + { + name: "OK: empty slice", + args: args{ + sort: nil, + }, + }, + { + name: "ERR: len(sort) > 3", + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortIDDESC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 3, + Current: 4, + }, + }, + }, + { + name: "ERR: conflict", + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortIDDESC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.SortConflictError{ + Sort: [2]string{domain.ServerSnapshotSortIDASC.String(), domain.ServerSnapshotSortIDDESC.String()}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListServerSnapshotsParams() + + require.ErrorIs(t, params.SetSort(tt.args.sort), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.sort, params.Sort()) + }) + } +} + +func TestListServerSnapshotsParams_PrependSort(t *testing.T) { + t.Parallel() + + defaultNewParams := func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + return domain.ListServerSnapshotsParams{} + } + + type args struct { + sort []domain.ServerSnapshotSort + } + + tests := []struct { + name string + newParams func(t *testing.T) domain.ListServerSnapshotsParams + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortDateASC, + }, + }, + }, + { + name: "OK: custom params", + newParams: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortServerKeyASC, + })) + return params + }, + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + }, + }, + }, + { + name: "OK: empty slice", + newParams: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortServerKeyASC, + })) + return params + }, + args: args{ + sort: nil, + }, + }, + { + name: "ERR: custom params + len(sort) > sortMaxLength - len(sort)", + newParams: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortServerKeyASC, + })) + return params + }, + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateASC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 1, + Current: 2, + }, + }, + }, + { + name: "ERR: len(sort) > 3", + newParams: defaultNewParams, + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateASC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 3, + Current: 4, + }, + }, + }, + { + name: "ERR: conflict", + args: args{ + sort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateDESC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.SortConflictError{ + Sort: [2]string{domain.ServerSnapshotSortDateASC.String(), domain.ServerSnapshotSortDateDESC.String()}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + newParams := defaultNewParams + if tt.newParams != nil { + newParams = tt.newParams + } + params := newParams(t) + + expectedSort := params.Sort() + + require.ErrorIs(t, params.PrependSort(tt.args.sort), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, append(tt.args.sort, expectedSort...), params.Sort()) + }) + } +} + +func TestListServerSnapshotsParams_PrependSortString(t *testing.T) { + t.Parallel() + + defaultNewParams := func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + return domain.ListServerSnapshotsParams{} + } + defaultAllowed := []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortDateDESC, + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortIDDESC, + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortServerKeyDESC, + } + defaultMaxLength := 3 + + type args struct { + sort []string + allowed []domain.ServerSnapshotSort + maxLength int + } + + tests := []struct { + name string + newParams func(t *testing.T) domain.ListServerSnapshotsParams + args args + expectedSort []domain.ServerSnapshotSort + expectedErr error + }{ + { + name: "OK: [id:ASC, date:ASC, serverKey:ASC]", + args: args{ + sort: []string{ + "id:ASC", + "date:ASC", + "serverKey:ASC", + }, + allowed: defaultAllowed, + maxLength: defaultMaxLength, + }, + expectedSort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortServerKeyASC, + }, + }, + { + name: "OK: [id:DESC, date:DESC, serverKey:DESC]", + args: args{ + sort: []string{ + "id:DESC", + "date:DESC", + "serverKey:DESC", + }, + allowed: defaultAllowed, + maxLength: defaultMaxLength, + }, + expectedSort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDDESC, + domain.ServerSnapshotSortDateDESC, + domain.ServerSnapshotSortServerKeyDESC, + }, + }, + { + name: "OK: custom params", + newParams: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortServerKeyASC, + })) + return params + }, + args: args{ + sort: []string{ + "date:ASC", + }, + allowed: defaultAllowed, + maxLength: defaultMaxLength, + }, + expectedSort: []domain.ServerSnapshotSort{ + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortIDASC, + domain.ServerSnapshotSortServerKeyASC, + }, + }, + { + name: "OK: empty slice", + args: args{ + sort: nil, + }, + }, + { + name: "ERR: custom params + len(sort) > sortMaxLength - len(sort)", + newParams: func(t *testing.T) domain.ListServerSnapshotsParams { + t.Helper() + params := domain.NewListServerSnapshotsParams() + require.NoError(t, params.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortIDASC, + })) + return params + }, + args: args{ + sort: []string{ + "date:ASC", + "date:DESC", + }, + allowed: defaultAllowed, + maxLength: defaultMaxLength, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 1, + Current: 2, + }, + }, + }, + { + name: "ERR: len(sort) > maxLength", + newParams: defaultNewParams, + args: args{ + sort: []string{ + "serverKey:ASC", + "date:ASC", + }, + allowed: defaultAllowed, + maxLength: 1, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 1, + Current: 2, + }, + }, + }, + { + name: "ERR: unsupported sort string", + newParams: defaultNewParams, + args: args{ + sort: []string{ + "date:", + }, + allowed: defaultAllowed, + maxLength: defaultMaxLength, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Index: 0, + Err: domain.UnsupportedSortStringError{ + Sort: "date:", + }, + }, + }, + { + name: "ERR: conflict", + args: args{ + sort: []string{ + "date:ASC", + "date:DESC", + }, + allowed: defaultAllowed, + maxLength: defaultMaxLength, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "sort", + Err: domain.SortConflictError{ + Sort: [2]string{domain.ServerSnapshotSortDateASC.String(), domain.ServerSnapshotSortDateDESC.String()}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + newParams := defaultNewParams + if tt.newParams != nil { + newParams = tt.newParams + } + params := newParams(t) + + require.ErrorIs(t, params.PrependSortString(tt.args.sort, tt.args.allowed, tt.args.maxLength), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.expectedSort, params.Sort()) + }) + } +} + +func TestListServerSnapshotsParams_SetEncodedCursor(t *testing.T) { + t.Parallel() + + validCursor := domaintest.NewServerSnapshotCursor(t) + + type args struct { + cursor string + } + + tests := []struct { + name string + args args + expectedCursor domain.ServerSnapshotCursor + expectedErr error + }{ + { + name: "OK", + args: args{ + cursor: validCursor.Encode(), + }, + expectedCursor: validCursor, + }, + { + name: "ERR: len(cursor) < 1", + args: args{ + cursor: "", + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "cursor", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 1000, + Current: 0, + }, + }, + }, + { + name: "ERR: len(cursor) > 1000", + args: args{ + cursor: gofakeit.LetterN(1001), + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "cursor", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 1000, + Current: 1001, + }, + }, + }, + { + name: "ERR: malformed base64", + args: args{ + cursor: "112345", + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "cursor", + Err: domain.ErrInvalidCursor, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListServerSnapshotsParams() + + require.ErrorIs(t, params.SetEncodedCursor(tt.args.cursor), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.cursor, params.Cursor().Encode()) + }) + } +} + +func TestListServerSnapshotsParams_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.ServerSnapshotListMaxLimit, + }, + }, + { + name: "ERR: limit < 1", + args: args{ + limit: 0, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "limit", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + { + name: fmt.Sprintf("ERR: limit > %d", domain.ServerSnapshotListMaxLimit), + args: args{ + limit: domain.ServerSnapshotListMaxLimit + 1, + }, + expectedErr: domain.ValidationError{ + Model: "ListServerSnapshotsParams", + Field: "limit", + Err: domain.MaxLessEqualError{ + Max: domain.ServerSnapshotListMaxLimit, + Current: domain.ServerSnapshotListMaxLimit + 1, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListServerSnapshotsParams() + + require.ErrorIs(t, params.SetLimit(tt.args.limit), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.limit, params.Limit()) + }) + } +} + +func TestNewListServerSnapshotsResult(t *testing.T) { + t.Parallel() + + snapshots := domain.ServerSnapshots{ + domaintest.NewServerSnapshot(t), + domaintest.NewServerSnapshot(t), + domaintest.NewServerSnapshot(t), + } + next := domaintest.NewServerSnapshot(t) + + t.Run("OK: with next", func(t *testing.T) { + t.Parallel() + + res, err := domain.NewListServerSnapshotsResult(snapshots, next) + require.NoError(t, err) + assert.Equal(t, snapshots, res.ServerSnapshots()) + assert.Equal(t, snapshots[0].ID(), res.Self().ID()) + assert.Equal(t, snapshots[0].ServerKey(), res.Self().ServerKey()) + assert.Equal(t, snapshots[0].Date(), res.Self().Date()) + assert.Equal(t, next.ID(), res.Next().ID()) + assert.Equal(t, next.ServerKey(), res.Next().ServerKey()) + assert.Equal(t, next.Date(), res.Next().Date()) + }) + + t.Run("OK: without next", func(t *testing.T) { + t.Parallel() + + res, err := domain.NewListServerSnapshotsResult(snapshots, domain.ServerSnapshot{}) + require.NoError(t, err) + assert.Equal(t, snapshots, res.ServerSnapshots()) + assert.Equal(t, snapshots[0].ID(), res.Self().ID()) + assert.Equal(t, snapshots[0].ServerKey(), res.Self().ServerKey()) + assert.Equal(t, snapshots[0].Date(), res.Self().Date()) + assert.True(t, res.Next().IsZero()) + }) + + t.Run("OK: 0 snapshots", func(t *testing.T) { + t.Parallel() + + res, err := domain.NewListServerSnapshotsResult(nil, domain.ServerSnapshot{}) + require.NoError(t, err) + assert.Zero(t, res.ServerSnapshots()) + assert.True(t, res.Self().IsZero()) + assert.True(t, res.Next().IsZero()) + }) +} diff --git a/internal/domain/tribe_snapshot.go b/internal/domain/tribe_snapshot.go index ad9b471..1ea5bbb 100644 --- a/internal/domain/tribe_snapshot.go +++ b/internal/domain/tribe_snapshot.go @@ -318,7 +318,6 @@ func NewTribeSnapshotCursor(id int, serverKey string, date time.Time) (TribeSnap }, nil } -//nolint:gocyclo func decodeTribeSnapshotCursor(encoded string) (TribeSnapshotCursor, error) { m, err := decodeCursor(encoded) if err != nil { diff --git a/internal/domain/tribe_snapshot_test.go b/internal/domain/tribe_snapshot_test.go index 5a1b1fb..2d28e9c 100644 --- a/internal/domain/tribe_snapshot_test.go +++ b/internal/domain/tribe_snapshot_test.go @@ -176,7 +176,7 @@ func TestNewTribeSnapshotCursor(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - psc, err := domain.NewTribeSnapshotCursor( + tsc, err := domain.NewTribeSnapshotCursor( tt.args.id, tt.args.serverKey, tt.args.date, @@ -185,10 +185,10 @@ func TestNewTribeSnapshotCursor(t *testing.T) { if tt.expectedErr != nil { return } - assert.Equal(t, tt.args.id, psc.ID()) - assert.Equal(t, tt.args.serverKey, psc.ServerKey()) - assert.Equal(t, tt.args.date, psc.Date()) - assert.NotEmpty(t, psc.Encode()) + assert.Equal(t, tt.args.id, tsc.ID()) + assert.Equal(t, tt.args.serverKey, tsc.ServerKey()) + assert.Equal(t, tt.args.date, tsc.Date()) + assert.NotEmpty(t, tsc.Encode()) }) } } diff --git a/internal/port/consumer_data_sync_test.go b/internal/port/consumer_data_sync_test.go index bc452f3..e1f7e55 100644 --- a/internal/port/consumer_data_sync_test.go +++ b/internal/port/consumer_data_sync_test.go @@ -158,10 +158,12 @@ func TestDataSync(t *testing.T) { ctx, port.NewServerWatermillConsumer( serverSvc, + nil, serverSub, nopLogger, marshaler, serverCmdSync, + "", serverEventSynced, tribeEventSynced, playerEventSynced, diff --git a/internal/port/consumer_ennoblement_sync_test.go b/internal/port/consumer_ennoblement_sync_test.go index c9445ae..bc93464 100644 --- a/internal/port/consumer_ennoblement_sync_test.go +++ b/internal/port/consumer_ennoblement_sync_test.go @@ -138,6 +138,7 @@ func TestEnnoblementSync(t *testing.T) { ctx, port.NewServerWatermillConsumer( serverSvc, + nil, serverSub, nopLogger, marshaler, @@ -146,6 +147,7 @@ func TestEnnoblementSync(t *testing.T) { "", "", "", + "", ennoblementEventSynced, "", "", diff --git a/internal/port/consumer_snapshot_creation_test.go b/internal/port/consumer_snapshot_creation_test.go index b772210..3129d83 100644 --- a/internal/port/consumer_snapshot_creation_test.go +++ b/internal/port/consumer_snapshot_creation_test.go @@ -70,6 +70,7 @@ func TestSnapshotCreation(t *testing.T) { ) // events/commands + serverSnapshotCmdCreate := gofakeit.UUID() tribeSnapshotCmdCreate := gofakeit.UUID() tribeSnapshotEventCreated := gofakeit.UUID() playerSnapshotCmdCreate := gofakeit.UUID() @@ -80,8 +81,15 @@ func TestSnapshotCreation(t *testing.T) { serverRepo := adapter.NewServerBunRepository(db) tribeRepo := adapter.NewTribeBunRepository(db) playerRepo := adapter.NewPlayerBunRepository(db) + serverSnapshotRepo := adapter.NewServerSnapshotBunRepository(db) tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(db) playerSnapshotRepo := adapter.NewPlayerSnapshotBunRepository(db) + serverSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( + tribePub, + marshaler, + serverSnapshotCmdCreate, + "", + ) tribeSnapshotPublisher := adapter.NewSnapshotWatermillPublisher( tribePub, marshaler, @@ -100,19 +108,28 @@ func TestSnapshotCreation(t *testing.T) { serverSvc := app.NewServerService(serverRepo, nil, nil) tribeSvc := app.NewTribeService(tribeRepo, nil, nil) playerSvc := app.NewPlayerService(playerRepo, nil, nil, nil) + serverSnapshotSvc := app.NewServerSnapshotService(serverSnapshotRepo, serverSvc, serverSnapshotPublisher) tribeSnapshotSvc := app.NewTribeSnapshotService(tribeSnapshotRepo, tribeSvc, tribeSnapshotPublisher) playerSnapshotSvc := app.NewPlayerSnapshotService(playerSnapshotRepo, playerSvc, playerSnapshotPublisher) - snapshotSvc := app.NewSnapshotService(versionSvc, serverSvc, tribeSnapshotPublisher, playerSnapshotPublisher) + snapshotSvc := app.NewSnapshotService( + versionSvc, + serverSvc, + serverSnapshotPublisher, + tribeSnapshotPublisher, + playerSnapshotPublisher, + ) watermilltest.RunRouterWithContext( t, ctx, port.NewServerWatermillConsumer( serverSvc, + serverSnapshotSvc, serverSub, nopLogger, marshaler, "", + serverSnapshotCmdCreate, "", "", "", @@ -155,31 +172,85 @@ func TestSnapshotCreation(t *testing.T) { assert.EventuallyWithTf(t, func(collect *assert.CollectT) { require.NoError(collect, ctx.Err()) - listParams := domain.NewListServersParams() - require.NoError(collect, listParams.SetSort([]domain.ServerSort{ + listServersParams := domain.NewListServersParams() + require.NoError(collect, listServersParams.SetSort([]domain.ServerSort{ domain.ServerSortKeyASC, })) - require.NoError(collect, listParams.SetSpecial(domain.NullBool{ + require.NoError(collect, listServersParams.SetSpecial(domain.NullBool{ V: false, Valid: true, })) - require.NoError(collect, listParams.SetLimit(domain.ServerListMaxLimit)) + require.NoError(collect, listServersParams.SetLimit(domain.ServerListMaxLimit)) + + var allServers domain.Servers for { - res, err := serverRepo.List(ctx, listParams) + res, err := serverRepo.List(ctx, listServersParams) require.NoError(collect, err) for _, s := range res.Servers() { + assert.WithinDuration(collect, time.Now(), s.SnapshotCreatedAt(), time.Minute, s.Key()) assert.WithinDuration(collect, time.Now(), s.PlayerSnapshotsCreatedAt(), time.Minute, s.Key()) assert.WithinDuration(collect, time.Now(), s.TribeSnapshotsCreatedAt(), time.Minute, s.Key()) } + allServers = append(allServers, res.Servers()...) + if res.Next().IsZero() { - return + break } - require.NoError(collect, listParams.SetCursor(res.Next())) + require.NoError(collect, listServersParams.SetCursor(res.Next())) } + + listSnapshotsParams := domain.NewListServerSnapshotsParams() + require.NoError(collect, listSnapshotsParams.SetSort([]domain.ServerSnapshotSort{ + domain.ServerSnapshotSortServerKeyASC, + domain.ServerSnapshotSortDateASC, + domain.ServerSnapshotSortIDASC, + })) + require.NoError(collect, listSnapshotsParams.SetLimit(domain.ServerSnapshotListMaxLimit)) + + cnt := 0 + + for { + res, err := serverSnapshotRepo.List(ctx, listSnapshotsParams) + require.NoError(collect, err) + + for _, ss := range res.ServerSnapshots() { + cnt++ + msg := fmt.Sprintf("ServerKey=%s", ss.ServerKey()) + + idx := slices.IndexFunc(allServers, func(s domain.Server) bool { + return s.Key() == ss.ServerKey() + }) + if !assert.GreaterOrEqual( + collect, + idx, + 0, + msg, + ) { + continue + } + + server := allServers[idx] + + assert.NotZero(collect, ss.ID(), msg) + assert.Equal(collect, server.Key(), ss.ServerKey(), msg) + assert.Equal(collect, server.NumVillages(), ss.NumVillages(), msg) + assert.WithinDuration(collect, time.Now(), ss.CreatedAt(), time.Minute, msg) + assert.WithinDuration(collect, time.Now(), ss.Date(), 24*time.Hour, msg) + } + + if res.Next().IsZero() { + break + } + + require.NoError(collect, listSnapshotsParams.SetCursor(res.Next())) + } + + //nolint:testifylint + assert.Equal(collect, len(allServers), cnt) }, 30*time.Second, 500*time.Millisecond, "servers") }() diff --git a/internal/port/consumer_watermill_server.go b/internal/port/consumer_watermill_server.go index 3de84bc..9fd713b 100644 --- a/internal/port/consumer_watermill_server.go +++ b/internal/port/consumer_watermill_server.go @@ -10,10 +10,12 @@ import ( type ServerWatermillConsumer struct { svc *app.ServerService + snapshotSvc *app.ServerSnapshotService subscriber message.Subscriber logger watermill.LoggerAdapter marshaler watermillmsg.Marshaler cmdSyncTopic string + cmdCreateSnapshotsTopic string eventServerSyncedTopic string eventTribesSyncedTopic string eventPlayersSyncedTopic string @@ -25,10 +27,12 @@ type ServerWatermillConsumer struct { func NewServerWatermillConsumer( svc *app.ServerService, + snapshotSvc *app.ServerSnapshotService, subscriber message.Subscriber, logger watermill.LoggerAdapter, marshaler watermillmsg.Marshaler, cmdSyncTopic string, + cmdCreateSnapshotsTopic string, eventServerSyncedTopic string, eventTribesSyncedTopic string, eventPlayersSyncedTopic string, @@ -39,10 +43,12 @@ func NewServerWatermillConsumer( ) *ServerWatermillConsumer { return &ServerWatermillConsumer{ svc: svc, + snapshotSvc: snapshotSvc, subscriber: subscriber, logger: logger, marshaler: marshaler, cmdSyncTopic: cmdSyncTopic, + cmdCreateSnapshotsTopic: cmdCreateSnapshotsTopic, eventServerSyncedTopic: eventServerSyncedTopic, eventTribesSyncedTopic: eventTribesSyncedTopic, eventPlayersSyncedTopic: eventPlayersSyncedTopic, @@ -55,6 +61,12 @@ func NewServerWatermillConsumer( func (c *ServerWatermillConsumer) Register(router *message.Router) { router.AddNoPublisherHandler("ServerConsumer.sync", c.cmdSyncTopic, c.subscriber, c.sync) + router.AddNoPublisherHandler( + "ServerConsumer.createSnapshots", + c.cmdCreateSnapshotsTopic, + c.subscriber, + c.createSnapshots, + ) router.AddNoPublisherHandler( "ServerConsumer.syncConfigAndInfo", c.eventServerSyncedTopic, @@ -141,6 +153,32 @@ func (c *ServerWatermillConsumer) syncConfigAndInfo(msg *message.Message) error return c.svc.SyncConfigAndInfo(msg.Context(), payload) } +func (c *ServerWatermillConsumer) 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) +} + func (c *ServerWatermillConsumer) updateNumTribes(msg *message.Message) error { var rawPayload watermillmsg.TribesSyncedEventPayload