diff --git a/cmd/dcbot/internal/run/run.go b/cmd/dcbot/internal/run/run.go index 4e39cb8..e630a15 100644 --- a/cmd/dcbot/internal/run/run.go +++ b/cmd/dcbot/internal/run/run.go @@ -47,36 +47,23 @@ func New() *cli.Command { } groupRepo := bundb.NewGroup(db) - monitorRepo := bundb.NewMonitor(db) choiceSvc := service.NewChoice(client) - groupSvc := service.NewGroup( - groupRepo, - client, - logger, - cfg.MaxGroupsPerServer, - ) - monitorSvc := service.NewMonitor( - monitorRepo, - groupRepo, - client, - logger, - cfg.MaxMonitorsPerGroup, - ) + groupSvc := service.NewGroup(groupRepo, client, logger, cfg.MaxGroupsPerServer, cfg.MaxMonitorsPerGroup) - bot, err := discord.NewBot(cfg.Token, groupSvc, monitorSvc, choiceSvc, logger) + bot, err := discord.NewBot(cfg.Token, groupSvc, choiceSvc, logger) if err != nil { return fmt.Errorf("discord.NewBot: %w", err) } + defer func() { + _ = bot.Close() + }() go func() { if runErr := bot.Run(); runErr != nil { logger.Fatal("something went wrong while starting the bot", zap.Error(runErr)) } }() - defer func() { - _ = bot.Close() - }() if err = os.WriteFile(healthyFilePath, []byte("healthy"), healthyFilePerm); err != nil { return fmt.Errorf("couldn't create file (path=%s): %w", healthyFilePath, err) @@ -93,7 +80,7 @@ func New() *cli.Command { type botConfig struct { Token string `envconfig:"TOKEN" required:"true"` - MaxGroupsPerServer int `envconfig:"MAX_GROUPS_PER_SERVER" default:"10"` + MaxGroupsPerServer int `envconfig:"MAX_GROUPS_PER_SERVER" default:"5"` MaxMonitorsPerGroup int `envconfig:"MAX_MONITORS_PER_GROUP" default:"10"` } diff --git a/cmd/dcbot/internal/twhelp.go b/cmd/dcbot/internal/twhelp.go index 68e8851..f555968 100644 --- a/cmd/dcbot/internal/twhelp.go +++ b/cmd/dcbot/internal/twhelp.go @@ -8,11 +8,15 @@ import ( "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" "github.com/kelseyhightower/envconfig" + "golang.org/x/time/rate" ) type twhelpClientConfig struct { - URL *url.URL `envconfig:"URL" required:"true"` - Timeout time.Duration `envconfig:"TIMEOUT" default:"10s"` + URL *url.URL `envconfig:"URL" required:"true"` + Timeout time.Duration `envconfig:"TIMEOUT" default:"10s"` + RateLimiterEnabled bool `envconfig:"RATE_LIMITER_ENABLED" default:"false"` + RateLimiterMaxRequestsPerSecond float64 `envconfig:"RATE_LIMITER_MAX_REQUESTS_PER_SECOND" default:"10"` + RateLimiterRequestBurst int `envconfig:"RATE_LIMITER_REQUEST_BURST" default:"5"` } func NewTWHelpClient(version string) (*twhelp.Client, error) { @@ -21,11 +25,16 @@ func NewTWHelpClient(version string) (*twhelp.Client, error) { return nil, fmt.Errorf("envconfig.Process: %w", err) } - return twhelp.NewClient( - cfg.URL, + opts := []twhelp.ClientOption{ twhelp.WithHTTPClient(&http.Client{ Timeout: cfg.Timeout, }), - twhelp.WithUserAgent("TWHelpDCBot/"+version), - ), nil + twhelp.WithUserAgent("TWHelpDCBot/" + version), + } + + if cfg.RateLimiterEnabled { + opts = append(opts, twhelp.WithRateLimiter(rate.NewLimiter(rate.Limit(cfg.RateLimiterMaxRequestsPerSecond), cfg.RateLimiterRequestBurst))) + } + + return twhelp.NewClient(cfg.URL, opts...), nil } diff --git a/go.mod b/go.mod index 396dfb3..ef0c058 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/uptrace/bun/driver/pgdriver v1.1.14 github.com/urfave/cli/v2 v2.25.5 go.uber.org/zap v1.24.0 + golang.org/x/time v0.3.0 ) require ( diff --git a/go.sum b/go.sum index 21a60a2..ed0af9c 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/bundb/bundb_test.go b/internal/bundb/bundb_test.go index 739b065..32020cb 100644 --- a/internal/bundb/bundb_test.go +++ b/internal/bundb/bundb_test.go @@ -149,7 +149,7 @@ func loadFixtures(tb testing.TB, bunDB *bun.DB) *bunfixture { return &bunfixture{fixture} } -func (f *bunfixture) group(tb testing.TB, id string) domain.Group { +func (f *bunfixture) group(tb testing.TB, id string) domain.GroupWithMonitors { tb.Helper() row, err := f.Row("Group." + id) @@ -159,13 +159,13 @@ func (f *bunfixture) group(tb testing.TB, id string) domain.Group { return g.ToDomain() } -func (f *bunfixture) groups(tb testing.TB) []domain.Group { +func (f *bunfixture) groups(tb testing.TB) []domain.GroupWithMonitors { tb.Helper() //nolint:lll ids := []string{"group-1-server-1", "group-2-server-1", "group-1-server-2", "group-2-server-2", "group-3-server-1", "group-3-server-2"} - groups := make([]domain.Group, 0, len(ids)) + groups := make([]domain.GroupWithMonitors, 0, len(ids)) for _, id := range ids { groups = append(groups, f.group(tb, id)) } @@ -173,6 +173,16 @@ func (f *bunfixture) groups(tb testing.TB) []domain.Group { return groups } +func (f *bunfixture) monitor(tb testing.TB, id string) domain.Monitor { + tb.Helper() + + row, err := f.Row("Monitor." + id) + require.NoError(tb, err) + m, ok := row.(*model.Monitor) + require.True(tb, ok) + return m.ToDomain() +} + func generateSchema() string { return strings.TrimFunc(strings.ReplaceAll(uuid.NewString(), "-", "_"), unicode.IsNumber) } diff --git a/internal/bundb/group.go b/internal/bundb/group.go index d7fbf5a..7b5e129 100644 --- a/internal/bundb/group.go +++ b/internal/bundb/group.go @@ -9,7 +9,9 @@ import ( "gitea.dwysokinski.me/twhelp/dcbot/internal/bundb/internal/model" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "github.com/google/uuid" + "github.com/jackc/pgerrcode" "github.com/uptrace/bun" + "github.com/uptrace/bun/driver/pgdriver" ) type Group struct { @@ -20,7 +22,7 @@ func NewGroup(db *bun.DB) *Group { return &Group{db: db} } -func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error) { +func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) { group := model.Group{ ServerID: params.ServerID(), VersionCode: params.VersionCode(), @@ -35,52 +37,103 @@ func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (do Model(&group). Returning("*"). Exec(ctx); err != nil { - return domain.Group{}, fmt.Errorf("something went wrong while inserting group into the db: %w", err) + return domain.GroupWithMonitors{}, fmt.Errorf("something went wrong while inserting group into the db: %w", err) } return group.ToDomain(), nil } -func (g *Group) Update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.Group, error) { +func (g *Group) Update(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { if params.IsZero() { - return domain.Group{}, domain.ErrNothingToUpdate + return domain.GroupWithMonitors{}, domain.ErrNothingToUpdate } if _, err := uuid.Parse(id); err != nil { - return domain.Group{}, domain.GroupNotFoundError{ID: id} + return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ID: id} } var group model.Group res, err := g.db.NewUpdate(). Model(&group). - Returning("*"). + Returning("NULL"). Where("id = ?", id). - Where("server_id = ?", serverID). Apply(updateGroupsParamsApplier{params}.apply). Exec(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return domain.Group{}, fmt.Errorf("couldn't update group (id=%s,serverID=%s): %w", id, serverID, err) + return domain.GroupWithMonitors{}, fmt.Errorf("couldn't update group (id=%s): %w", id, err) } if affected, _ := res.RowsAffected(); affected == 0 { - return domain.Group{}, domain.GroupNotFoundError{ID: id} + return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ID: id} } - return group.ToDomain(), nil + return g.Get(ctx, id) } -func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) { +func (g *Group) AddMonitor(ctx context.Context, id string, tribeID int64) (domain.GroupWithMonitors, error) { + parsedID, err := uuid.Parse(id) + if err != nil { + return domain.GroupWithMonitors{}, domain.GroupDoesNotExistError{ID: id} + } + + monitor := model.Monitor{ + GroupID: parsedID, + TribeID: tribeID, + } + + if _, err = g.db.NewInsert(). + Model(&monitor). + Returning("NULL"). + Exec(ctx); err != nil { + + return domain.GroupWithMonitors{}, fmt.Errorf( + "something went wrong while inserting monitor into the db: %w", + mapAddMonitorError(err, id, tribeID), + ) + } + + return g.Get(ctx, id) +} + +func (g *Group) DeleteMonitors(ctx context.Context, id string, monitorIDs ...string) (domain.GroupWithMonitors, error) { + if _, err := uuid.Parse(id); err != nil { + return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ID: id} + } + + for _, monitorID := range monitorIDs { + if _, err := uuid.Parse(monitorID); err != nil { + return domain.GroupWithMonitors{}, domain.MonitorNotFoundError{ID: monitorID} + } + } + + _, err := g.db.NewDelete(). + Model(&model.Monitor{}). + Returning("NULL"). + Where("id IN (?)", bun.In(monitorIDs)). + Where("group_id = ?", id). + Exec(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return domain.GroupWithMonitors{}, fmt.Errorf("couldn't delete monitors: %w", err) + } + + return g.Get(ctx, id) +} + +func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]domain.GroupWithMonitors, error) { var groups []model.Group if err := g.db.NewSelect(). Model(&groups). Order("created_at ASC"). + Relation("Monitors", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("created_at ASC") + }). Apply(listGroupsParamsApplier{params}.apply). Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("couldn't select groups from the db: %w", err) } - result := make([]domain.Group, 0, len(groups)) + result := make([]domain.GroupWithMonitors, 0, len(groups)) for _, group := range groups { result = append(result, group.ToDomain()) } @@ -88,29 +141,41 @@ func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]dom return result, nil } -func (g *Group) Get(ctx context.Context, id, serverID string) (domain.Group, error) { +func (g *Group) Get(ctx context.Context, id string) (domain.GroupWithMonitors, error) { + group, err := g.get(ctx, id, true) + if err != nil { + return domain.GroupWithMonitors{}, err + } + return group.ToDomain(), nil +} + +func (g *Group) get(ctx context.Context, id string, withMonitors bool) (model.Group, error) { if _, err := uuid.Parse(id); err != nil { - return domain.Group{}, domain.GroupNotFoundError{ID: id} + return model.Group{}, domain.GroupNotFoundError{ID: id} } var group model.Group - err := g.db.NewSelect(). + q := g.db.NewSelect(). Model(&group). - Where("id = ?", id). - Where("server_id = ?", serverID). - Scan(ctx) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return domain.Group{}, domain.GroupNotFoundError{ID: id} - } - return domain.Group{}, fmt.Errorf("couldn't select group (id=%s,serverID=%s) from the db: %w", id, serverID, err) + Where("id = ?", id) + if withMonitors { + q = q.Relation("Monitors", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("created_at ASC") + }) } - return group.ToDomain(), nil + if err := q.Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.Group{}, domain.GroupNotFoundError{ID: id} + } + return model.Group{}, fmt.Errorf("couldn't select group (id=%s) from the db: %w", id, err) + } + + return group, nil } -func (g *Group) Delete(ctx context.Context, id, serverID string) error { +func (g *Group) Delete(ctx context.Context, id string) error { if _, err := uuid.Parse(id); err != nil { return domain.GroupNotFoundError{ID: id} } @@ -119,14 +184,14 @@ func (g *Group) Delete(ctx context.Context, id, serverID string) error { Model(&model.Group{}). Returning("NULL"). Where("id = ?", id). - Where("server_id = ?", serverID). Exec(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("couldn't delete group (id=%s,serverID=%s): %w", id, serverID, err) + return fmt.Errorf("couldn't delete group (id=%s): %w", id, err) } if affected, _ := res.RowsAffected(); affected == 0 { return domain.GroupNotFoundError{ID: id} } + return nil } @@ -183,7 +248,7 @@ type listGroupsParamsApplier struct { } func (l listGroupsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { - if l.params.ServerIDs != nil { + if len(l.params.ServerIDs) > 0 { q = q.Where("server_id IN (?)", bun.In(l.params.ServerIDs)) } @@ -202,7 +267,7 @@ func (l listGroupsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Where("version_code = ?", l.params.VersionCode.String) } - if l.params.ServerKeys != nil { + if len(l.params.ServerKeys) > 0 { q = q.Where("server_key IN (?)", bun.In(l.params.ServerKeys)) } @@ -212,3 +277,26 @@ func (l listGroupsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { return q } + +func mapAddMonitorError(err error, groupID string, tribeID int64) error { + var pgError pgdriver.Error + if !errors.As(err, &pgError) { + return err + } + + code := pgError.Field('C') + constraint := pgError.Field('n') + switch { + case code == pgerrcode.ForeignKeyViolation && constraint == "monitors_group_id_fkey": + return domain.GroupDoesNotExistError{ + ID: groupID, + } + case code == pgerrcode.UniqueViolation && constraint == "monitors_group_id_tribe_id_key": + return domain.MonitorAlreadyExistsError{ + TribeID: tribeID, + GroupID: groupID, + } + default: + return err + } +} diff --git a/internal/bundb/group_test.go b/internal/bundb/group_test.go index 29047de..2bc3558 100644 --- a/internal/bundb/group_test.go +++ b/internal/bundb/group_test.go @@ -37,8 +37,7 @@ func TestGroup_Create(t *testing.T) { group, err := repo.Create(context.Background(), params) assert.NoError(t, err) - _, err = uuid.Parse(group.ID) - assert.NoError(t, err) + assert.NotZero(t, group.ID) assert.Equal(t, params.ServerID(), group.ServerID) assert.Equal(t, params.ServerKey(), group.ServerKey) assert.Equal(t, params.VersionCode(), group.VersionCode) @@ -84,7 +83,7 @@ func TestGroup_Update(t *testing.T) { }, } - updatedGroup, err := repo.Update(context.Background(), group.ID, group.ServerID, params) + updatedGroup, err := repo.Update(context.Background(), group.ID, params) assert.NoError(t, err) assert.Equal(t, params.ChannelGains.String, updatedGroup.ChannelGains) assert.Equal(t, params.ChannelLosses.String, updatedGroup.ChannelLosses) @@ -95,7 +94,7 @@ func TestGroup_Update(t *testing.T) { t.Run("ERR: nothing to update", func(t *testing.T) { t.Parallel() - updatedGroup, err := repo.Update(context.Background(), "", "", domain.UpdateGroupParams{}) + updatedGroup, err := repo.Update(context.Background(), "", domain.UpdateGroupParams{}) assert.ErrorIs(t, err, domain.ErrNothingToUpdate) assert.Zero(t, updatedGroup) }) @@ -104,24 +103,16 @@ func TestGroup_Update(t *testing.T) { t.Parallel() tests := []struct { - name string - id string - serverID string + name string + id string }{ { - name: "ID - not UUID", - id: "test", - serverID: group.ServerID, + name: "ID - not UUID", + id: "test", }, { - name: "ID - random UUID", - id: uuid.NewString(), - serverID: group.ServerID, - }, - { - name: "ServerID - random UUID", - id: group.ID, - serverID: uuid.NewString(), + name: "ID - random UUID", + id: uuid.NewString(), }, } @@ -131,7 +122,7 @@ func TestGroup_Update(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - updatedGroup, err := repo.Update(context.Background(), tt.id, tt.serverID, domain.UpdateGroupParams{ + updatedGroup, err := repo.Update(context.Background(), tt.id, domain.UpdateGroupParams{ ChannelGains: domain.NullString{ String: "update", Valid: true, @@ -144,6 +135,168 @@ func TestGroup_Update(t *testing.T) { }) } +func TestGroup_AddMonitor(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long-running test") + } + + db := newDB(t) + fixture := loadFixtures(t, db) + repo := bundb.NewGroup(db) + group := fixture.group(t, "group-1-server-1") + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var tribeID int64 = 91283718 + + updatedGroup, err := repo.AddMonitor(context.Background(), group.ID, tribeID) + assert.NoError(t, err) + var found bool + for _, m := range updatedGroup.Monitors { + if m.GroupID == group.ID && m.TribeID == tribeID { + found = true + break + } + } + assert.True(t, found) + }) + + t.Run("ERR: group doesn't exist", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + }{ + { + name: "ID - not UUID", + id: "test", + }, + { + name: "ID - random UUID", + id: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + updatedGroup, err := repo.AddMonitor(context.Background(), tt.id, 125) + assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ID: tt.id}) + assert.Zero(t, updatedGroup) + }) + } + }) + + t.Run("ERR: monitor already exists", func(t *testing.T) { + t.Parallel() + + monitor := fixture.monitor(t, "monitor-2-group-1-server-1") + + updatedGroup, err := repo.AddMonitor(context.Background(), monitor.GroupID, monitor.TribeID) + assert.ErrorIs(t, err, domain.MonitorAlreadyExistsError{ + TribeID: monitor.TribeID, + GroupID: monitor.GroupID, + }) + assert.Zero(t, updatedGroup) + }) +} + +func TestGroup_DeleteMonitors(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long-running test") + } + + db := newDB(t) + fixture := loadFixtures(t, db) + repo := bundb.NewGroup(db) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + monitor := fixture.monitor(t, "monitor-1-group-1-server-1") + + updatedGroup, err := repo.DeleteMonitors(context.Background(), monitor.GroupID, monitor.ID) + assert.NoError(t, err) + var found bool + for _, m := range updatedGroup.Monitors { + if m.ID == monitor.ID { + found = true + break + } + } + assert.False(t, found) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + monitor := fixture.monitor(t, "monitor-2-group-1-server-1") + + tests := []struct { + name string + id string + }{ + { + name: "group ID - not UUID", + id: "id", + }, + { + name: "group ID - not UUID", + id: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + updatedGroup, err := repo.DeleteMonitors(context.Background(), tt.id, monitor.ID) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: tt.id}) + assert.Zero(t, updatedGroup) + }) + } + }) + + t.Run("ERR: monitor not found", func(t *testing.T) { + t.Parallel() + + monitor := fixture.monitor(t, "monitor-2-group-1-server-1") + + tests := []struct { + name string + monitorID string + }{ + { + name: "monitor ID - not UUID", + monitorID: "id", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + updatedGroup, err := repo.DeleteMonitors(context.Background(), monitor.GroupID, tt.monitorID) + assert.ErrorIs(t, err, domain.MonitorNotFoundError{ID: tt.monitorID}) + assert.Zero(t, updatedGroup) + }) + } + }) +} + func TestGroup_List(t *testing.T) { t.Parallel() @@ -299,33 +452,25 @@ func TestGroup_Get(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() - g, err := repo.Get(context.Background(), group.ID, group.ServerID) + g, err := repo.Get(context.Background(), group.ID) assert.NoError(t, err) - assert.Equal(t, group, g) + assert.Equal(t, group.ID, g.ID) }) t.Run("ERR: group not found (unknown ID)", func(t *testing.T) { t.Parallel() tests := []struct { - name string - id string - serverID string + name string + id string }{ { - name: "ID - not UUID", - id: "test", - serverID: group.ServerID, + name: "ID - not UUID", + id: "test", }, { - name: "ID - random UUID", - id: uuid.NewString(), - serverID: group.ServerID, - }, - { - name: "ServerID - random UUID", - id: group.ID, - serverID: uuid.NewString(), + name: "ID - random UUID", + id: uuid.NewString(), }, } @@ -335,7 +480,7 @@ func TestGroup_Get(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - g, err := repo.Get(context.Background(), tt.id, tt.serverID) + g, err := repo.Get(context.Background(), tt.id) assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: tt.id}) assert.Zero(t, g) }) @@ -353,7 +498,6 @@ func TestGroup_Delete(t *testing.T) { db := newDB(t) fixture := loadFixtures(t, db) groupRepo := bundb.NewGroup(db) - monitorRepo := bundb.NewMonitor(db) group := fixture.group(t, "group-1-server-1") t.Run("OK", func(t *testing.T) { @@ -364,42 +508,29 @@ func TestGroup_Delete(t *testing.T) { }) assert.NoError(t, err) - assert.NoError(t, groupRepo.Delete(context.Background(), group.ID, group.ServerID)) + assert.NoError(t, groupRepo.Delete(context.Background(), group.ID)) afterDelete, err := groupRepo.List(context.Background(), domain.ListGroupsParams{ ServerIDs: []string{group.ServerID}, }) assert.NoError(t, err) assert.Len(t, afterDelete, len(beforeDelete)-1) - - // monitors should also be deleted - monitors, err := monitorRepo.List(context.Background(), group.ID) - assert.NoError(t, err) - assert.Len(t, monitors, 0) }) t.Run("ERR: group not found", func(t *testing.T) { t.Parallel() tests := []struct { - name string - id string - serverID string + name string + id string }{ { - name: "ID - not UUID", - id: "test", - serverID: group.ServerID, + name: "ID - not UUID", + id: "test", }, { - name: "ID - random UUID", - id: uuid.NewString(), - serverID: group.ServerID, - }, - { - name: "ServerID - random UUID", - id: group.ID, - serverID: uuid.NewString(), + name: "ID - random UUID", + id: uuid.NewString(), }, } @@ -409,11 +540,7 @@ func TestGroup_Delete(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.ErrorIs( - t, - groupRepo.Delete(context.Background(), tt.id, tt.serverID), - domain.GroupNotFoundError{ID: tt.id}, - ) + assert.ErrorIs(t, groupRepo.Delete(context.Background(), tt.id), domain.GroupNotFoundError{ID: tt.id}) }) } }) @@ -429,7 +556,6 @@ func TestGroup_DeleteMany(t *testing.T) { db := newDB(t) fixture := loadFixtures(t, db) groupRepo := bundb.NewGroup(db) - monitorRepo := bundb.NewMonitor(db) group1 := fixture.group(t, "group-1-server-1") group2 := fixture.group(t, "group-2-server-1") ids := []string{group1.ID, group2.ID} @@ -450,13 +576,6 @@ func TestGroup_DeleteMany(t *testing.T) { }) assert.NoError(t, err) assert.Len(t, afterDelete, len(beforeDelete)-len(ids)) - - // monitors should also be deleted - for _, id := range ids { - monitors, err := monitorRepo.List(context.Background(), id) - assert.NoError(t, err) - assert.Len(t, monitors, 0) - } }) t.Run("OK: 0 ids", func(t *testing.T) { diff --git a/internal/bundb/internal/model/group.go b/internal/bundb/internal/model/group.go index 11b84d6..f9e7e05 100644 --- a/internal/bundb/internal/model/group.go +++ b/internal/bundb/internal/model/group.go @@ -20,18 +20,26 @@ type Group struct { ServerKey string `bun:"server_key,nullzero"` VersionCode string `bun:"version_code,nullzero"` CreatedAt time.Time `bun:"created_at,nullzero"` + Monitors []Monitor `bun:"monitors,rel:has-many,join:id=group_id"` } -func (g Group) ToDomain() domain.Group { - return domain.Group{ - ID: g.ID.String(), - ServerID: g.ServerID, - ChannelGains: g.ChannelGains, - ChannelLosses: g.ChannelLosses, - Internals: g.Internals, - Barbarians: g.Barbarians, - ServerKey: g.ServerKey, - VersionCode: g.VersionCode, - CreatedAt: g.CreatedAt, +func (g Group) ToDomain() domain.GroupWithMonitors { + monitors := make([]domain.Monitor, 0, len(g.Monitors)) + for _, m := range g.Monitors { + monitors = append(monitors, m.ToDomain()) + } + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: g.ID.String(), + ServerID: g.ServerID, + ChannelGains: g.ChannelGains, + ChannelLosses: g.ChannelLosses, + Internals: g.Internals, + Barbarians: g.Barbarians, + ServerKey: g.ServerKey, + VersionCode: g.VersionCode, + CreatedAt: g.CreatedAt, + }, + Monitors: monitors, } } diff --git a/internal/bundb/monitor.go b/internal/bundb/monitor.go deleted file mode 100644 index 2a27504..0000000 --- a/internal/bundb/monitor.go +++ /dev/null @@ -1,137 +0,0 @@ -package bundb - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "gitea.dwysokinski.me/twhelp/dcbot/internal/bundb/internal/model" - "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" - "github.com/google/uuid" - "github.com/jackc/pgerrcode" - "github.com/uptrace/bun" - "github.com/uptrace/bun/driver/pgdriver" -) - -type Monitor struct { - db *bun.DB -} - -func NewMonitor(db *bun.DB) *Monitor { - return &Monitor{db: db} -} - -func (m *Monitor) Create(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) { - groupID, err := uuid.Parse(params.GroupID()) - if err != nil { - return domain.Monitor{}, domain.GroupDoesNotExistError{ - ID: params.GroupID(), - } - } - - monitor := model.Monitor{ - GroupID: groupID, - TribeID: params.TribeID(), - } - - if _, err = m.db.NewInsert(). - Model(&monitor). - Returning("*"). - Exec(ctx); err != nil { - - return domain.Monitor{}, fmt.Errorf( - "something went wrong while inserting monitor into the db: %w", - mapCreateMonitorError(err, params), - ) - } - - return monitor.ToDomain(), nil -} - -func (m *Monitor) List(ctx context.Context, groupID string) ([]domain.Monitor, error) { - if _, err := uuid.Parse(groupID); err != nil { - return nil, nil - } - - var monitors []model.Monitor - - if err := m.db.NewSelect(). - Model(&monitors). - Order("created_at ASC"). - Where("group_id = ?", groupID). - Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("couldn't select monitors from the db: %w", err) - } - - result := make([]domain.Monitor, 0, len(monitors)) - for _, monitor := range monitors { - result = append(result, monitor.ToDomain()) - } - - return result, nil -} - -func (m *Monitor) Get(ctx context.Context, id string) (domain.Monitor, error) { - if _, err := uuid.Parse(id); err != nil { - return domain.Monitor{}, domain.MonitorNotFoundError{ID: id} - } - - var monitor model.Monitor - - err := m.db.NewSelect(). - Model(&monitor). - Where("id = ?", id). - Scan(ctx) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return domain.Monitor{}, domain.MonitorNotFoundError{ID: id} - } - return domain.Monitor{}, fmt.Errorf("couldn't select monitor from the db (id=%s): %w", id, err) - } - - return monitor.ToDomain(), nil -} - -func (m *Monitor) Delete(ctx context.Context, id string) error { - if _, err := uuid.Parse(id); err != nil { - return domain.MonitorNotFoundError{ID: id} - } - - res, err := m.db.NewDelete(). - Model(&model.Monitor{}). - Returning("NULL"). - Where("id = ?", id). - Exec(ctx) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("couldn't delete monitor (id=%s): %w", id, err) - } - if affected, _ := res.RowsAffected(); affected == 0 { - return domain.MonitorNotFoundError{ID: id} - } - - return nil -} - -func mapCreateMonitorError(err error, params domain.CreateMonitorParams) error { - var pgError pgdriver.Error - if !errors.As(err, &pgError) { - return err - } - - code := pgError.Field('C') - constraint := pgError.Field('n') - switch { - case code == pgerrcode.ForeignKeyViolation && constraint == "monitors_group_id_fkey": - return domain.GroupDoesNotExistError{ - ID: params.GroupID(), - } - case code == pgerrcode.UniqueViolation && constraint == "monitors_group_id_tribe_id_key": - return domain.MonitorAlreadyExistsError{ - TribeID: params.TribeID(), - GroupID: params.GroupID(), - } - default: - return err - } -} diff --git a/internal/bundb/monitor_test.go b/internal/bundb/monitor_test.go deleted file mode 100644 index 8e74e42..0000000 --- a/internal/bundb/monitor_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package bundb_test - -import ( - "context" - "testing" - "time" - - "gitea.dwysokinski.me/twhelp/dcbot/internal/bundb" - "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMonitor_Create(t *testing.T) { - t.Parallel() - - if testing.Short() { - t.Skip("skipping long-running test") - } - - db := newDB(t) - fixture := loadFixtures(t, db) - repo := bundb.NewMonitor(db) - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - group := fixture.group(t, "group-1-server-1") - - params, err := domain.NewCreateMonitorParams(group.ID, 1234) - require.Zero(t, err) - - monitor, err := repo.Create(context.Background(), params) - assert.NoError(t, err) - _, err = uuid.Parse(monitor.ID) - assert.NoError(t, err) - assert.Equal(t, params.GroupID(), monitor.GroupID) - assert.Equal(t, params.TribeID(), monitor.TribeID) - assert.WithinDuration(t, time.Now(), monitor.CreatedAt, 1*time.Second) - - // check unique index - monitor, err = repo.Create(context.Background(), params) - assert.ErrorIs(t, err, domain.MonitorAlreadyExistsError{ - TribeID: params.TribeID(), - GroupID: params.GroupID(), - }) - assert.Zero(t, monitor) - }) - - t.Run("ERR: group doesn't exist", func(t *testing.T) { - t.Parallel() - - t.Run("foreign key violation", func(t *testing.T) { - t.Parallel() - - groupID := uuid.NewString() - params, err := domain.NewCreateMonitorParams(groupID, 1234) - require.Zero(t, err) - - monitor, err := repo.Create(context.Background(), params) - assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ - ID: groupID, - }) - assert.Zero(t, monitor) - }) - - t.Run("not UUID", func(t *testing.T) { - t.Parallel() - - groupID := "592292203234328587" - params, err := domain.NewCreateMonitorParams(groupID, 1234) - require.Zero(t, err) - - monitor, err := repo.Create(context.Background(), params) - assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ - ID: groupID, - }) - assert.Zero(t, monitor) - }) - }) -} - -func TestMonitor_List(t *testing.T) { - t.Parallel() - - if testing.Short() { - t.Skip("skipping long-running test") - } - - db := newDB(t) - fixture := loadFixtures(t, db) - repo := bundb.NewMonitor(db) - - tests := []struct { - name string - groupID string - expectedMonitors []string - }{ - { - name: "group-1-server-1", - groupID: fixture.group(t, "group-1-server-1").ID, - expectedMonitors: []string{ - "e3017ba8-4fba-4bb1-ac8d-e4e9477a04d2", - "89719460-58ab-46b8-8682-46f161546949", - }, - }, - { - name: "group-2-server-2", - groupID: fixture.group(t, "group-2-server-2").ID, - expectedMonitors: []string{ - "c7d63c3d-55f6-432e-b9d8-006f8c5ab407", - }, - }, - { - name: "random UUID", - groupID: uuid.NewString(), - expectedMonitors: nil, - }, - { - name: "not UUID", - groupID: "test", - expectedMonitors: nil, - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - res, err := repo.List(context.Background(), tt.groupID) - assert.NoError(t, err) - assert.Len(t, res, len(tt.expectedMonitors)) - for _, id := range tt.expectedMonitors { - found := false - for _, m := range res { - if m.ID == id { - found = true - break - } - } - assert.True(t, found, "monitor (id=%s) not found", id) - } - }) - } -} - -func TestMonitor_Get(t *testing.T) { - t.Parallel() - - if testing.Short() { - t.Skip("skipping long-running test") - } - - db := newDB(t) - fixture := loadFixtures(t, db) - repo := bundb.NewMonitor(db) - group := fixture.group(t, "group-1-server-1") - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - monitors, err := repo.List(context.Background(), group.ID) - require.NoError(t, err) - require.Greater(t, len(monitors), 0) - - m, err := repo.Get(context.Background(), monitors[0].ID) - assert.NoError(t, err) - assert.Equal(t, monitors[0], m) - }) - - t.Run("ERR: monitor not found", func(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - id string - }{ - { - name: "ID - not UUID", - id: "test", - }, - { - name: "ID - random UUID", - id: uuid.NewString(), - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - monitor, err := repo.Get(context.Background(), tt.id) - assert.ErrorIs(t, err, domain.MonitorNotFoundError{ID: tt.id}) - assert.Zero(t, monitor) - }) - } - }) -} - -func TestMonitor_Delete(t *testing.T) { - t.Parallel() - - if testing.Short() { - t.Skip("skipping long-running test") - } - - db := newDB(t) - fixture := loadFixtures(t, db) - repo := bundb.NewMonitor(db) - group := fixture.group(t, "group-1-server-1") - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - beforeDelete, err := repo.List(context.Background(), group.ID) - assert.NoError(t, err) - assert.Greater(t, len(beforeDelete), 0) - - assert.NoError(t, repo.Delete(context.Background(), beforeDelete[0].ID)) - - afterDelete, err := repo.List(context.Background(), group.ID) - assert.NoError(t, err) - assert.Len(t, afterDelete, len(beforeDelete)-1) - }) - - t.Run("ERR: monitor not found", func(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - id string - }{ - { - name: "ID - not UUID", - id: "test", - }, - { - name: "ID - random UUID", - id: uuid.NewString(), - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - assert.ErrorIs( - t, - repo.Delete(context.Background(), tt.id), - domain.MonitorNotFoundError{ID: tt.id}, - ) - }) - } - }) -} diff --git a/internal/discord/bot.go b/internal/discord/bot.go index 5f21023..8e5a3fd 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -12,20 +12,17 @@ import ( ) type GroupService interface { - Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error) - SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.Group, error) - SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.Group, error) - SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.Group, error) - SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.Group, error) - CleanUp(ctx context.Context) error - List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) - Delete(ctx context.Context, id, serverID string) error -} - -type MonitorService interface { - Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) - List(ctx context.Context, groupID, serverID string) ([]domain.MonitorWithTribe, error) + Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) + AddMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) + DeleteMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) + SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) + SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) + SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.GroupWithMonitors, error) + SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.GroupWithMonitors, error) Execute(ctx context.Context) ([]domain.EnnoblementNotification, error) + CleanUp(ctx context.Context) error + ListServer(ctx context.Context, serverID string) ([]domain.GroupWithMonitors, error) + GetWithTribes(ctx context.Context, id, serverID string) (domain.GroupWithMonitorsAndTribes, error) Delete(ctx context.Context, id, serverID string) error } @@ -34,18 +31,16 @@ type ChoiceService interface { } type Bot struct { - s *discordgo.Session - c *cron.Cron - groupSvc GroupService - monitorSvc MonitorService - choiceSvc ChoiceService - logger *zap.Logger + s *discordgo.Session + c *cron.Cron + groupSvc GroupService + choiceSvc ChoiceService + logger *zap.Logger } func NewBot( token string, groupSvc GroupService, - monitorSvc MonitorService, client ChoiceService, logger *zap.Logger, ) (*Bot, error) { @@ -64,10 +59,9 @@ func NewBot( cron.SkipIfStillRunning(cron.DiscardLogger), ), ), - groupSvc: groupSvc, - monitorSvc: monitorSvc, - choiceSvc: client, - logger: logger, + groupSvc: groupSvc, + choiceSvc: client, + logger: logger, } b.s.AddHandler(b.handleSessionReady) @@ -103,7 +97,6 @@ type command interface { func (b *Bot) registerCommands() error { commands := []command{ &groupCommand{groupSvc: b.groupSvc, choiceSvc: b.choiceSvc}, - &monitorCommand{svc: b.monitorSvc}, } for _, c := range commands { @@ -129,7 +122,7 @@ func (b *Bot) initCron() error { }{ { spec: "@every 1m", - job: &executeMonitorsJob{svc: b.monitorSvc, s: b.s, logger: b.logger}, + job: &executeMonitorsJob{svc: b.groupSvc, s: b.s, logger: b.logger}, }, { spec: "0 */8 * * *", @@ -159,7 +152,8 @@ func (b *Bot) logCommands(_ *discordgo.Session, i *discordgo.InteractionCreate) cmdData := i.ApplicationCommandData() cmd := cmdData.Name options := cmdData.Options - for len(options) > 0 && options[0].Type == discordgo.ApplicationCommandOptionSubCommand { + for len(options) > 0 && + (options[0].Type == discordgo.ApplicationCommandOptionSubCommand || options[0].Type == discordgo.ApplicationCommandOptionSubCommandGroup) { cmd += " " + options[0].Name options = options[0].Options } diff --git a/internal/discord/command_group.go b/internal/discord/command_group.go index 71f6208..f048100 100644 --- a/internal/discord/command_group.go +++ b/internal/discord/command_group.go @@ -3,12 +3,17 @@ package discord import ( "context" "fmt" + "strings" "time" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "github.com/bwmarrin/discordgo" ) +const ( + tribeTagMaxLength = 10 +) + type groupCommand struct { groupSvc GroupService choiceSvc ChoiceService @@ -98,9 +103,69 @@ func (c *groupCommand) create(s *discordgo.Session) error { Description: "Lists all created groups", Type: discordgo.ApplicationCommandOptionSubCommand, }, + { + Name: "details", + Description: "Displays group details (including tribes added to the group)", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "group", + Description: "Group ID", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + }, + }, + { + Name: "monitor", + Description: "Manages monitors", + Type: discordgo.ApplicationCommandOptionSubCommandGroup, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "add", + Description: "Adds a new monitor to a group", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "group", + Description: "Group ID", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "tag", + Description: "Tribe tag", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + MaxLength: tribeTagMaxLength, + }, + }, + }, + { + Name: "delete", + Description: "Deletes a monitor", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "group", + Description: "Group ID", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "tag", + Description: "Tribe tag", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + MaxLength: tribeTagMaxLength, + }, + }, + }, + }, + }, { Name: "set", - Description: "Sets various properties in a group configuration", + Description: "Sets various properties in group configuration", Type: discordgo.ApplicationCommandOptionSubCommandGroup, Options: []*discordgo.ApplicationCommandOption{ { @@ -266,29 +331,27 @@ func (c *groupCommand) handle(s *discordgo.Session, i *discordgo.InteractionCrea return } + ctx := context.Background() + switch cmdData.Options[0].Name { case "create": - c.handleCreate(s, i) - return + c.handleCreate(ctx, s, i) case "list": - c.handleList(s, i) - return + c.handleList(ctx, s, i) + case "details": + c.handleDetails(ctx, s, i) case "set": - c.handleSet(s, i) - return + c.handleSet(ctx, s, i) case "unset": - c.handleUnset(s, i) - return + c.handleUnset(ctx, s, i) + case "monitor": + c.handleMonitor(ctx, s, i) case "delete": - c.handleDelete(s, i) - return - default: + c.handleDelete(ctx, s, i) } } -func (c *groupCommand) handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleCreate(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { version := "" server := "" channelGains := "" @@ -345,17 +408,13 @@ func (c *groupCommand) handleCreate(s *discordgo.Session, i *discordgo.Interacti _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: "group has been successfully created (ID=" + group.ID + ")", + Content: "group has been successfully created (id=" + group.ID + ")", }, }) } -func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - - groups, err := c.groupSvc.List(ctx, domain.ListGroupsParams{ - ServerIDs: []string{i.GuildID}, - }) +func (c *groupCommand) handleList(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + groups, err := c.groupSvc.ListServer(ctx, i.GuildID) if err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, @@ -379,12 +438,13 @@ func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.Interaction fields = append(fields, &discordgo.MessageEmbedField{ Name: g.ID, Value: fmt.Sprintf( - "**Server**: %s\n**Channel gains**: %s\n**Channel losses**: %s\n**Internals**: %s\n **Barbarians**: %s", + "**Server**: %s\n**Channel gains**: %s\n**Channel losses**: %s\n**Internals**: %s\n **Barbarians**: %s\n **Number of monitored monitors**: %d", g.ServerKey, channelGains, channelLosses, boolToEmoji(g.Internals), boolToEmoji(g.Barbarians), + len(g.Monitors), ), Inline: false, }) @@ -405,27 +465,75 @@ func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.Interaction }) } -func (c *groupCommand) handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) { +func (c *groupCommand) handleDetails(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + g, err := c.groupSvc.GetWithTribes(ctx, i.ApplicationCommandData().Options[0].Options[0].StringValue(), i.GuildID) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: messageFromError(err), + }, + }) + return + } + + var builderMonitors strings.Builder + for i, m := range g.Monitors { + if i > 0 { + builderMonitors.WriteString(" ") + } + builderMonitors.WriteString(buildLink(m.Tribe.Tag, m.Tribe.ProfileURL)) + } + if builderMonitors.Len() == 0 { + builderMonitors.WriteString("None") + } + + channelGains := "Not set" + if g.ChannelGains != "" { + channelGains = buildChannelMention(g.ChannelGains) + } + channelLosses := "Not set" + if g.ChannelLosses != "" { + channelLosses = buildChannelMention(g.ChannelLosses) + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Type: discordgo.EmbedTypeRich, + Title: g.ID, + Description: fmt.Sprintf( + "**Server**: %s\n**Channel gains**: %s\n**Channel losses**: %s\n**Internals**: %s\n **Barbarians**: %s\n **Monitored tribes**: %s", + g.ServerKey, + channelGains, + channelLosses, + boolToEmoji(g.Internals), + boolToEmoji(g.Barbarians), + builderMonitors.String(), + ), + Timestamp: formatTimestamp(time.Now()), + }, + }, + }, + }) +} + +func (c *groupCommand) handleSet(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.ApplicationCommandData().Options[0].Options[0].Name { case "channel-gains": - c.handleSetChannelGains(s, i) - return + c.handleSetChannelGains(ctx, s, i) case "channel-losses": - c.handleSetChannelLosses(s, i) - return + c.handleSetChannelLosses(ctx, s, i) case "internals": - c.handleSetInternals(s, i) - return + c.handleSetInternals(ctx, s, i) case "barbarians": - c.handleSetBarbarians(s, i) - return - default: + c.handleSetBarbarians(ctx, s, i) } } -func (c *groupCommand) handleSetChannelGains(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleSetChannelGains(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := "" channel := "" for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { @@ -459,9 +567,7 @@ func (c *groupCommand) handleSetChannelGains(s *discordgo.Session, i *discordgo. }) } -func (c *groupCommand) handleSetChannelLosses(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleSetChannelLosses(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := "" channel := "" for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { @@ -495,9 +601,7 @@ func (c *groupCommand) handleSetChannelLosses(s *discordgo.Session, i *discordgo }) } -func (c *groupCommand) handleSetInternals(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleSetInternals(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := "" internals := false for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { @@ -531,9 +635,7 @@ func (c *groupCommand) handleSetInternals(s *discordgo.Session, i *discordgo.Int }) } -func (c *groupCommand) handleSetBarbarians(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleSetBarbarians(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := "" barbarians := false for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { @@ -567,21 +669,16 @@ func (c *groupCommand) handleSetBarbarians(s *discordgo.Session, i *discordgo.In }) } -func (c *groupCommand) handleUnset(s *discordgo.Session, i *discordgo.InteractionCreate) { +func (c *groupCommand) handleUnset(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.ApplicationCommandData().Options[0].Options[0].Name { case "channel-gains": - c.handleUnsetChannelGains(s, i) - return + c.handleUnsetChannelGains(ctx, s, i) case "channel-losses": - c.handleUnsetChannelLosses(s, i) - return - default: + c.handleUnsetChannelLosses(ctx, s, i) } } -func (c *groupCommand) handleUnsetChannelGains(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleUnsetChannelGains(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := i.ApplicationCommandData().Options[0].Options[0].Options[0].StringValue() _, err := c.groupSvc.SetChannelGains(ctx, group, i.GuildID, "") if err != nil { @@ -602,9 +699,7 @@ func (c *groupCommand) handleUnsetChannelGains(s *discordgo.Session, i *discordg }) } -func (c *groupCommand) handleUnsetChannelLosses(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - +func (c *groupCommand) handleUnsetChannelLosses(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := i.ApplicationCommandData().Options[0].Options[0].Options[0].StringValue() _, err := c.groupSvc.SetChannelLosses(ctx, group, i.GuildID, "") if err != nil { @@ -625,9 +720,84 @@ func (c *groupCommand) handleUnsetChannelLosses(s *discordgo.Session, i *discord }) } -func (c *groupCommand) handleDelete(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() +func (c *groupCommand) handleMonitor(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.ApplicationCommandData().Options[0].Options[0].Name { + case "add": + c.handleMonitorAdd(ctx, s, i) + case "delete": + c.handleMonitorDelete(ctx, s, i) + } +} +func (c *groupCommand) handleMonitorAdd(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + group := "" + tag := "" + for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { + if opt == nil { + continue + } + switch opt.Name { + case "group": + group = opt.StringValue() + case "tag": + tag = opt.StringValue() + } + } + + _, err := c.groupSvc.AddMonitor(ctx, group, i.GuildID, tag) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: messageFromError(err), + }, + }) + return + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "monitor has been successfully added", + }, + }) +} + +func (c *groupCommand) handleMonitorDelete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + group := "" + tag := "" + for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options { + if opt == nil { + continue + } + switch opt.Name { + case "group": + group = opt.StringValue() + case "tag": + tag = opt.StringValue() + } + } + + _, err := c.groupSvc.DeleteMonitor(ctx, group, i.GuildID, tag) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: messageFromError(err), + }, + }) + return + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "monitor has been successfully deleted", + }, + }) +} + +func (c *groupCommand) handleDelete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { group := i.ApplicationCommandData().Options[0].Options[0].StringValue() if err := c.groupSvc.Delete(ctx, group, i.GuildID); err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ diff --git a/internal/discord/command_monitor.go b/internal/discord/command_monitor.go deleted file mode 100644 index 31e6ea9..0000000 --- a/internal/discord/command_monitor.go +++ /dev/null @@ -1,215 +0,0 @@ -package discord - -import ( - "context" - "fmt" - "time" - - "github.com/bwmarrin/discordgo" -) - -const ( - tribeTagMaxLength = 10 -) - -type monitorCommand struct { - svc MonitorService -} - -func (c *monitorCommand) name() string { - return "monitor" -} - -func (c *monitorCommand) register(s *discordgo.Session) error { - if err := c.create(s); err != nil { - return err - } - - s.AddHandler(c.handle) - - return nil -} - -func (c *monitorCommand) create(s *discordgo.Session) error { - var perm int64 = discordgo.PermissionAdministrator - dm := false - - _, err := s.ApplicationCommandCreate(s.State.User.ID, "", &discordgo.ApplicationCommand{ - Name: c.name(), - Description: "Manages monitors", - DefaultMemberPermissions: &perm, - DMPermission: &dm, - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "create", - Description: "Creates a new monitor", - Type: discordgo.ApplicationCommandOptionSubCommand, - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "group", - Description: "Group ID", - Type: discordgo.ApplicationCommandOptionString, - Required: true, - }, - { - Name: "tag", - Description: "Tribe tag", - Type: discordgo.ApplicationCommandOptionString, - Required: true, - MaxLength: tribeTagMaxLength, - }, - }, - }, - { - Name: "list", - Description: "Lists all monitors associated with a particular group", - Type: discordgo.ApplicationCommandOptionSubCommand, - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "group", - Description: "Group ID", - Type: discordgo.ApplicationCommandOptionString, - Required: true, - }, - }, - }, - { - Name: "delete", - Description: "Deletes a monitor", - Type: discordgo.ApplicationCommandOptionSubCommand, - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "monitor", - Description: "Monitor ID", - Type: discordgo.ApplicationCommandOptionString, - Required: true, - }, - }, - }, - }, - }) - if err != nil { - return fmt.Errorf("s.ApplicationCommandCreate: %w", err) - } - - return nil -} - -func (c *monitorCommand) handle(s *discordgo.Session, i *discordgo.InteractionCreate) { - cmdData := i.ApplicationCommandData() - - if cmdData.Name != c.name() { - return - } - - switch cmdData.Options[0].Name { - case "create": - c.handleCreate(s, i) - return - case "list": - c.handleList(s, i) - return - case "delete": - c.handleDelete(s, i) - return - default: - } -} - -func (c *monitorCommand) handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - - group := "" - tag := "" - for _, opt := range i.ApplicationCommandData().Options[0].Options { - if opt == nil { - continue - } - switch opt.Name { - case "group": - group = opt.StringValue() - case "tag": - tag = opt.StringValue() - } - } - - monitor, err := c.svc.Create(ctx, group, i.GuildID, tag) - if err != nil { - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: messageFromError(err), - }, - }) - return - } - - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "monitor has been successfully created (ID=" + monitor.ID + ")", - }, - }) -} - -func (c *monitorCommand) handleList(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - - group := i.ApplicationCommandData().Options[0].Options[0].StringValue() - monitors, err := c.svc.List(ctx, group, i.GuildID) - if err != nil { - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: messageFromError(err), - }, - }) - return - } - - fields := make([]*discordgo.MessageEmbedField, 0, len(monitors)) - for _, m := range monitors { - fields = append(fields, &discordgo.MessageEmbedField{ - Name: m.ID, - Value: fmt.Sprintf("**Tribe**: %s", buildLink(m.Tribe.Tag, m.Tribe.ProfileURL)), - Inline: false, - }) - } - - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - { - Type: discordgo.EmbedTypeRich, - Title: "Monitor list", - Description: "**Group**: " + group, - Fields: fields, - Timestamp: formatTimestamp(time.Now()), - }, - }, - }, - }) -} - -func (c *monitorCommand) handleDelete(s *discordgo.Session, i *discordgo.InteractionCreate) { - ctx := context.Background() - - monitor := i.ApplicationCommandData().Options[0].Options[0].StringValue() - if err := c.svc.Delete(ctx, monitor, i.GuildID); err != nil { - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: messageFromError(err), - }, - }) - return - } - - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "monitor has been successfully deleted", - }, - }) -} diff --git a/internal/discord/job_execute_monitors.go b/internal/discord/job_execute_monitors.go index bd3aacf..7d7e885 100644 --- a/internal/discord/job_execute_monitors.go +++ b/internal/discord/job_execute_monitors.go @@ -19,7 +19,7 @@ const ( type executeMonitorsJob struct { s *discordgo.Session - svc MonitorService + svc GroupService logger *zap.Logger } diff --git a/internal/domain/ennoblement_notification.go b/internal/domain/ennoblement_notification.go index 839cc2d..201a3f2 100644 --- a/internal/domain/ennoblement_notification.go +++ b/internal/domain/ennoblement_notification.go @@ -3,7 +3,7 @@ package domain type EnnoblementNotificationType uint8 const ( - EnnoblementNotificationTypeGain = iota + EnnoblementNotificationTypeGain EnnoblementNotificationType = iota EnnoblementNotificationTypeLoss ) diff --git a/internal/domain/group.go b/internal/domain/group.go index ccf4689..bd1c5e0 100644 --- a/internal/domain/group.go +++ b/internal/domain/group.go @@ -17,6 +17,18 @@ type Group struct { CreatedAt time.Time } +type GroupWithMonitors struct { + Group + + Monitors []Monitor +} + +type GroupWithMonitorsAndTribes struct { + Group + + Monitors []MonitorWithTribe +} + type CreateGroupParams struct { serverID string serverKey string @@ -135,7 +147,7 @@ type GroupNotFoundError struct { } func (e GroupNotFoundError) Error() string { - return fmt.Sprintf("group (ID=%s) not found", e.ID) + return fmt.Sprintf("group (id=%s) not found", e.ID) } func (e GroupNotFoundError) UserError() string { @@ -151,7 +163,7 @@ type GroupDoesNotExistError struct { } func (e GroupDoesNotExistError) Error() string { - return fmt.Sprintf("group (ID=%s) doesn't exist", e.ID) + return fmt.Sprintf("group (id=%s) doesn't exist", e.ID) } func (e GroupDoesNotExistError) UserError() string { diff --git a/internal/domain/group_test.go b/internal/domain/group_test.go index 4e65031..d4e389c 100644 --- a/internal/domain/group_test.go +++ b/internal/domain/group_test.go @@ -204,7 +204,7 @@ func TestGroupNotFoundError(t *testing.T) { ID: uuid.NewString(), } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("group (ID=%s) not found", err.ID), err.Error()) + assert.Equal(t, fmt.Sprintf("group (id=%s) not found", err.ID), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code()) } @@ -216,7 +216,7 @@ func TestGroupDoesNotExistError(t *testing.T) { ID: uuid.NewString(), } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("group (ID=%s) doesn't exist", err.ID), err.Error()) + assert.Equal(t, fmt.Sprintf("group (id=%s) doesn't exist", err.ID), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeValidationError, err.Code()) } diff --git a/internal/domain/monitor.go b/internal/domain/monitor.go index 5432a7d..53db5d9 100644 --- a/internal/domain/monitor.go +++ b/internal/domain/monitor.go @@ -5,10 +5,6 @@ import ( "time" ) -const ( - tribeIDMin = 1 -) - type Monitor struct { ID string TribeID int64 @@ -21,46 +17,13 @@ type MonitorWithTribe struct { Tribe TribeMeta } -type CreateMonitorParams struct { - tribeID int64 - groupID string -} - -func NewCreateMonitorParams(groupID string, tribeID int64) (CreateMonitorParams, error) { - if groupID == "" { - return CreateMonitorParams{}, ValidationError{ - Field: "GroupID", - Err: ErrRequired, - } - } - - if tribeID < tribeIDMin { - return CreateMonitorParams{}, ValidationError{ - Field: "TribeID", - Err: MinError{ - Min: tribeIDMin, - }, - } - } - - return CreateMonitorParams{tribeID: tribeID, groupID: groupID}, nil -} - -func (c CreateMonitorParams) TribeID() int64 { - return c.tribeID -} - -func (c CreateMonitorParams) GroupID() string { - return c.groupID -} - type MonitorAlreadyExistsError struct { TribeID int64 GroupID string } func (e MonitorAlreadyExistsError) Error() string { - return fmt.Sprintf("monitor (GroupID=%s,TribeID=%d) already exists", e.GroupID, e.TribeID) + return fmt.Sprintf("monitor (groupID=%s,tribeID=%d) already exists", e.GroupID, e.TribeID) } func (e MonitorAlreadyExistsError) UserError() string { @@ -93,7 +56,7 @@ type MonitorNotFoundError struct { } func (e MonitorNotFoundError) Error() string { - return fmt.Sprintf("monitor (ID=%s) not found", e.ID) + return fmt.Sprintf("monitor (id=%s) not found", e.ID) } func (e MonitorNotFoundError) UserError() string { diff --git a/internal/domain/monitor_test.go b/internal/domain/monitor_test.go index ade4abf..8436789 100644 --- a/internal/domain/monitor_test.go +++ b/internal/domain/monitor_test.go @@ -9,63 +9,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewCreateMonitorParams(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - tribeID int64 - groupID string - err error - }{ - { - name: "OK", - tribeID: 15, - groupID: uuid.NewString(), - }, - { - name: "ERR: GroupID cannot be blank", - groupID: "", - err: domain.ValidationError{ - Field: "GroupID", - Err: domain.ErrRequired, - }, - }, - { - name: "ERR: TribeID < 1", - groupID: uuid.NewString(), - tribeID: 0, - err: domain.ValidationError{ - Field: "TribeID", - Err: domain.MinError{ - Min: 1, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - res, err := domain.NewCreateMonitorParams( - tt.groupID, - tt.tribeID, - ) - if tt.err != nil { - assert.ErrorIs(t, err, tt.err) - assert.Zero(t, res) - return - } - assert.NoError(t, err) - assert.Equal(t, tt.groupID, res.GroupID()) - assert.Equal(t, tt.tribeID, res.TribeID()) - }) - } -} - func TestMonitorAlreadyExistsError(t *testing.T) { t.Parallel() @@ -74,7 +17,7 @@ func TestMonitorAlreadyExistsError(t *testing.T) { TribeID: 1234, } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("monitor (GroupID=%s,TribeID=%d) already exists", err.GroupID, err.TribeID), err.Error()) + assert.Equal(t, fmt.Sprintf("monitor (groupID=%s,tribeID=%d) already exists", err.GroupID, err.TribeID), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeAlreadyExists, err.Code()) } @@ -99,7 +42,7 @@ func TestMonitorNotFoundError(t *testing.T) { ID: uuid.NewString(), } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("monitor (ID=%s) not found", err.ID), err.Error()) + assert.Equal(t, fmt.Sprintf("monitor (id=%s) not found", err.ID), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code()) } diff --git a/internal/domain/tw.go b/internal/domain/tw.go index dc51a44..b238efa 100644 --- a/internal/domain/tw.go +++ b/internal/domain/tw.go @@ -49,7 +49,7 @@ type ServerDoesNotExistError struct { } func (e ServerDoesNotExistError) Error() string { - return fmt.Sprintf("server (VersionCode=%s,Key=%s) doesn't exist", e.VersionCode, e.Key) + return fmt.Sprintf("server (versionCode=%s,key=%s) doesn't exist", e.VersionCode, e.Key) } func (e ServerDoesNotExistError) UserError() string { @@ -66,7 +66,7 @@ type ServerIsClosedError struct { } func (e ServerIsClosedError) Error() string { - return fmt.Sprintf("server (VersionCode=%s,Key=%s) is closed", e.VersionCode, e.Key) + return fmt.Sprintf("server (versionCode=%s,key=%s) is closed", e.VersionCode, e.Key) } func (e ServerIsClosedError) UserError() string { @@ -82,7 +82,7 @@ type TribeDoesNotExistError struct { } func (e TribeDoesNotExistError) Error() string { - return fmt.Sprintf("tribe (Tag=%s) doesn't exist", e.Tag) + return fmt.Sprintf("tribe (tag=%s) doesn't exist", e.Tag) } func (e TribeDoesNotExistError) UserError() string { @@ -92,3 +92,19 @@ func (e TribeDoesNotExistError) UserError() string { func (e TribeDoesNotExistError) Code() ErrorCode { return ErrorCodeValidationError } + +type TribeNotFoundError struct { + Tag string +} + +func (e TribeNotFoundError) Error() string { + return fmt.Sprintf("tribe (tag=%s) not found", e.Tag) +} + +func (e TribeNotFoundError) UserError() string { + return e.Error() +} + +func (e TribeNotFoundError) Code() ErrorCode { + return ErrorCodeEntityNotFound +} diff --git a/internal/domain/tw_test.go b/internal/domain/tw_test.go index f2331ff..0024887 100644 --- a/internal/domain/tw_test.go +++ b/internal/domain/tw_test.go @@ -16,7 +16,7 @@ func TestServerDoesNotExistError(t *testing.T) { Key: "pl151", } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("server (VersionCode=%s,Key=%s) doesn't exist", err.VersionCode, err.Key), err.Error()) + assert.Equal(t, fmt.Sprintf("server (versionCode=%s,key=%s) doesn't exist", err.VersionCode, err.Key), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeValidationError, err.Code()) } @@ -29,7 +29,7 @@ func TestServerIsClosedError(t *testing.T) { Key: "pl151", } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("server (VersionCode=%s,Key=%s) is closed", err.VersionCode, err.Key), err.Error()) + assert.Equal(t, fmt.Sprintf("server (versionCode=%s,key=%s) is closed", err.VersionCode, err.Key), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeValidationError, err.Code()) } @@ -41,7 +41,19 @@ func TestTribeDoesNotExistError(t *testing.T) { Tag: "*TAG*", } var _ domain.Error = err - assert.Equal(t, fmt.Sprintf("tribe (Tag=%s) doesn't exist", err.Tag), err.Error()) + assert.Equal(t, fmt.Sprintf("tribe (tag=%s) doesn't exist", err.Tag), err.Error()) assert.Equal(t, err.Error(), err.UserError()) assert.Equal(t, domain.ErrorCodeValidationError, err.Code()) } + +func TestTribeNotFoundError(t *testing.T) { + t.Parallel() + + err := domain.TribeNotFoundError{ + Tag: "*TAG*", + } + var _ domain.Error = err + assert.Equal(t, fmt.Sprintf("tribe (tag=%s) not found", err.Tag), err.Error()) + assert.Equal(t, err.Error(), err.UserError()) + assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code()) +} diff --git a/internal/service/ennoblement_notification_builder.go b/internal/service/ennoblement_notification_builder.go new file mode 100644 index 0000000..1c53d25 --- /dev/null +++ b/internal/service/ennoblement_notification_builder.go @@ -0,0 +1,148 @@ +package service + +import ( + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" + "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" +) + +type ennoblementNotificationBuilder struct { + groups []domain.GroupWithMonitors + ennoblements listEnnoblementsResult +} + +func (b ennoblementNotificationBuilder) build() []domain.EnnoblementNotification { + var notifications []domain.EnnoblementNotification + for _, g := range b.groups { + notifications = append(notifications, b.buildGroup(g)...) + } + return notifications +} + +func (b ennoblementNotificationBuilder) buildGroup(g domain.GroupWithMonitors) []domain.EnnoblementNotification { + var notifications []domain.EnnoblementNotification + + for _, e := range b.ennoblements.find(g.VersionCode, g.ServerKey) { + if b.canSendEnnoblementNotificationTypeGain(g, e) { + notifications = append(notifications, domain.EnnoblementNotification{ + Type: domain.EnnoblementNotificationTypeGain, + ServerID: g.ServerID, + ChannelID: g.ChannelGains, + Ennoblement: b.ennoblementToDomainModel(e), + }) + } + + if b.canSendEnnoblementNotificationTypeLoss(g, e) { + notifications = append(notifications, domain.EnnoblementNotification{ + Type: domain.EnnoblementNotificationTypeLoss, + ServerID: g.ServerID, + ChannelID: g.ChannelLosses, + Ennoblement: b.ennoblementToDomainModel(e), + }) + } + } + + return notifications +} + +func (b ennoblementNotificationBuilder) canSendEnnoblementNotificationTypeGain(g domain.GroupWithMonitors, e twhelp.Ennoblement) bool { + if g.ChannelGains == "" { + return false + } + + if !g.Barbarians && b.isBarbarian(e) { + return false + } + + if !g.Internals && b.isInternal(e, g.Monitors) { + return false + } + + return b.isGain(e, g.Monitors) +} + +func (b ennoblementNotificationBuilder) canSendEnnoblementNotificationTypeLoss(g domain.GroupWithMonitors, e twhelp.Ennoblement) bool { + if g.ChannelLosses == "" { + return false + } + + if b.isInternal(e, g.Monitors) { + return false + } + + return b.isLoss(e, g.Monitors) +} + +func (b ennoblementNotificationBuilder) isInternal(e twhelp.Ennoblement, monitors []domain.Monitor) bool { + var n, o bool + for _, m := range monitors { + if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID { + n = true + } + if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID { + o = true + } + } + return n && o +} + +func (b ennoblementNotificationBuilder) isBarbarian(e twhelp.Ennoblement) bool { + return !e.Village.Player.Valid +} + +func (b ennoblementNotificationBuilder) isGain(e twhelp.Ennoblement, monitors []domain.Monitor) bool { + var n bool + for _, m := range monitors { + if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID { + n = true + break + } + } + return n && e.NewOwner.Player.ID != e.Village.Player.Player.ID +} + +func (b ennoblementNotificationBuilder) isLoss(e twhelp.Ennoblement, monitors []domain.Monitor) bool { + var o bool + for _, m := range monitors { + if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID { + o = true + break + } + } + return o && e.NewOwner.Player.ID != e.Village.Player.Player.ID +} + +func (b ennoblementNotificationBuilder) ennoblementToDomainModel(e twhelp.Ennoblement) domain.Ennoblement { + return domain.Ennoblement{ + ID: e.ID, + Village: domain.VillageMeta{ + ID: e.Village.ID, + FullName: e.Village.FullName, + ProfileURL: e.Village.ProfileURL, + Player: domain.NullPlayerMeta{ + Player: domain.PlayerMeta{ + ID: e.Village.Player.Player.ID, + Name: e.Village.Player.Player.Name, + ProfileURL: e.Village.Player.Player.ProfileURL, + Tribe: domain.NullTribeMeta{ + Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe), + Valid: e.Village.Player.Player.Tribe.Valid, + }, + }, + Valid: e.Village.Player.Valid, + }, + }, + NewOwner: domain.NullPlayerMeta{ + Player: domain.PlayerMeta{ + ID: e.NewOwner.Player.ID, + Name: e.NewOwner.Player.Name, + ProfileURL: e.NewOwner.Player.ProfileURL, + Tribe: domain.NullTribeMeta{ + Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe), + Valid: e.NewOwner.Player.Tribe.Valid, + }, + }, + Valid: e.NewOwner.Valid, + }, + CreatedAt: e.CreatedAt, + } +} diff --git a/internal/service/group.go b/internal/service/group.go index 213a8d5..7df1f1d 100644 --- a/internal/service/group.go +++ b/internal/service/group.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "time" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" @@ -13,52 +14,58 @@ import ( //counterfeiter:generate -o internal/mock/group_repository.gen.go . GroupRepository type GroupRepository interface { - Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error) - Update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.Group, error) - List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) - Get(ctx context.Context, id, serverID string) (domain.Group, error) - Delete(ctx context.Context, id, serverID string) error + Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) + Update(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) + AddMonitor(ctx context.Context, id string, tribeID int64) (domain.GroupWithMonitors, error) + DeleteMonitors(ctx context.Context, id string, monitorID ...string) (domain.GroupWithMonitors, error) + List(ctx context.Context, params domain.ListGroupsParams) ([]domain.GroupWithMonitors, error) + Get(ctx context.Context, id string) (domain.GroupWithMonitors, error) + Delete(ctx context.Context, id string) error DeleteMany(ctx context.Context, id ...string) error } type Group struct { - repo GroupRepository - client TWHelpClient - logger *zap.Logger - maxGroupsPerServer int + repo GroupRepository + client TWHelpClient + logger *zap.Logger + maxGroupsPerServer int + maxMonitorsPerGroup int + ennoblementsMu sync.Mutex // ennoblementsMu is used by listEnnoblements + ennoblementsSince map[string]time.Time // ennoblementsSince is used by listEnnoblements } -func NewGroup(repo GroupRepository, client TWHelpClient, logger *zap.Logger, maxGroupsPerServer int) *Group { +func NewGroup(repo GroupRepository, client TWHelpClient, logger *zap.Logger, maxGroupsPerServer, maxMonitorsPerGroup int) *Group { return &Group{ - repo: repo, - client: client, - logger: logger, - maxGroupsPerServer: maxGroupsPerServer, + repo: repo, + client: client, + logger: logger, + maxGroupsPerServer: maxGroupsPerServer, + maxMonitorsPerGroup: maxMonitorsPerGroup, } } -func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error) { +func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) { groups, err := g.repo.List(ctx, domain.ListGroupsParams{ ServerIDs: []string{params.ServerID()}, }) if err != nil { - return domain.Group{}, fmt.Errorf("GroupRepository.List: %w", err) + return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.List: %w", err) } if len(groups) >= g.maxGroupsPerServer { - return domain.Group{}, domain.GroupLimitReachedError{ + return domain.GroupWithMonitors{}, domain.GroupLimitReachedError{ Current: len(groups), Limit: g.maxGroupsPerServer, } } if err = g.checkTWServer(ctx, params.VersionCode(), params.ServerKey()); err != nil { - return domain.Group{}, err + return domain.GroupWithMonitors{}, err } group, err := g.repo.Create(ctx, params) if err != nil { - return domain.Group{}, fmt.Errorf("GroupRepository.Create: %w", err) + return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.Create: %w", err) } return group, nil @@ -68,7 +75,7 @@ func (g *Group) checkTWServer(ctx context.Context, versionCode, serverKey string server, err := g.client.GetServer(ctx, versionCode, serverKey) if err != nil { var apiErr twhelp.APIError - if !errors.As(err, &apiErr) { + if !errors.As(err, &apiErr) || apiErr.Code != twhelp.ErrorCodeEntityNotFound { return fmt.Errorf("TWHelpClient.GetServer: %w", err) } return domain.ServerDoesNotExistError{ @@ -87,7 +94,86 @@ func (g *Group) checkTWServer(ctx context.Context, versionCode, serverKey string return nil } -func (g *Group) SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.Group, error) { +func (g *Group) AddMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) { + // check if group exists + groupBeforeUpdate, err := g.Get(ctx, id, serverID) + if err != nil { + if errors.Is(err, domain.GroupNotFoundError{ID: id}) { + return domain.GroupWithMonitors{}, domain.GroupDoesNotExistError{ID: id} + } + return domain.GroupWithMonitors{}, err + } + + // check limit + if len(groupBeforeUpdate.Monitors) >= g.maxMonitorsPerGroup { + return domain.GroupWithMonitors{}, domain.MonitorLimitReachedError{ + Current: len(groupBeforeUpdate.Monitors), + Limit: g.maxMonitorsPerGroup, + } + } + + tribes, err := g.client.ListTribes(ctx, groupBeforeUpdate.VersionCode, groupBeforeUpdate.ServerKey, twhelp.ListTribesQueryParams{ + Limit: 1, + Tags: []string{tribeTag}, + Deleted: twhelp.NullBool{ + Valid: true, + Bool: false, + }, + }) + if err != nil { + return domain.GroupWithMonitors{}, fmt.Errorf("TWHelpClient.ListTribes: %w", err) + } + if len(tribes) == 0 { + return domain.GroupWithMonitors{}, domain.TribeDoesNotExistError{ + Tag: tribeTag, + } + } + + group, err := g.repo.AddMonitor(ctx, groupBeforeUpdate.ID, tribes[0].ID) + if err != nil { + return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.AddMonitor: %w", err) + } + + return group, nil +} + +func (g *Group) DeleteMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) { + // check if group exists + groupBeforeUpdate, err := g.Get(ctx, id, serverID) + if err != nil { + return domain.GroupWithMonitors{}, err + } + + tribes, err := g.client.ListTribes(ctx, groupBeforeUpdate.VersionCode, groupBeforeUpdate.ServerKey, twhelp.ListTribesQueryParams{ + Tags: []string{tribeTag}, + }) + if err != nil { + return domain.GroupWithMonitors{}, fmt.Errorf("TWHelpClient.ListTribes: %w", err) + } + if len(tribes) == 0 { + return domain.GroupWithMonitors{}, domain.TribeNotFoundError{ + Tag: tribeTag, + } + } + + var ids []string + for _, t := range tribes { + for _, m := range groupBeforeUpdate.Monitors { + if m.TribeID == t.ID { + ids = append(ids, m.ID) + } + } + } + + group, err := g.repo.DeleteMonitors(ctx, groupBeforeUpdate.ID, ids...) + if err != nil { + return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.DeleteMonitors: %w", err) + } + + return group, nil +} + +func (g *Group) SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) { return g.update(ctx, id, serverID, domain.UpdateGroupParams{ ChannelGains: domain.NullString{ String: channel, @@ -96,7 +182,7 @@ func (g *Group) SetChannelGains(ctx context.Context, id, serverID, channel strin }) } -func (g *Group) SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.Group, error) { +func (g *Group) SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) { return g.update(ctx, id, serverID, domain.UpdateGroupParams{ ChannelLosses: domain.NullString{ String: channel, @@ -105,7 +191,7 @@ func (g *Group) SetChannelLosses(ctx context.Context, id, serverID, channel stri }) } -func (g *Group) SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.Group, error) { +func (g *Group) SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.GroupWithMonitors, error) { return g.update(ctx, id, serverID, domain.UpdateGroupParams{ Internals: domain.NullBool{ Bool: internals, @@ -114,7 +200,7 @@ func (g *Group) SetInternals(ctx context.Context, id, serverID string, internals }) } -func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.Group, error) { +func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.GroupWithMonitors, error) { return g.update(ctx, id, serverID, domain.UpdateGroupParams{ Barbarians: domain.NullBool{ Bool: barbarians, @@ -123,37 +209,249 @@ func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbaria }) } -func (g *Group) update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.Group, error) { - group, err := g.repo.Update(ctx, id, serverID, params) - if err != nil { - return domain.Group{}, fmt.Errorf("GroupRepository.Update: %w", err) +func (g *Group) update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { + // check if group exists + if _, err := g.Get(ctx, id, serverID); err != nil { + return domain.GroupWithMonitors{}, err } + + group, err := g.repo.Update(ctx, id, params) + if err != nil { + return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.Update: %w", err) + } + return group, nil } -func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) { - groups, err := g.repo.List(ctx, params) +func (g *Group) ListServer(ctx context.Context, serverID string) ([]domain.GroupWithMonitors, error) { + groups, err := g.repo.List(ctx, domain.ListGroupsParams{ + ServerIDs: []string{serverID}, + }) if err != nil { return nil, fmt.Errorf("GroupRepository.List: %w", err) } return groups, nil } -func (g *Group) Get(ctx context.Context, id, serverID string) (domain.Group, error) { - group, err := g.repo.Get(ctx, id, serverID) +func (g *Group) Get(ctx context.Context, id, serverID string) (domain.GroupWithMonitors, error) { + group, err := g.repo.Get(ctx, id) if err != nil { - return domain.Group{}, fmt.Errorf("GroupRepository.Get: %w", err) + return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.Get: %w", err) } + + if group.ServerID != serverID { + return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ + ID: id, + } + } + return group, nil } +type getTribeResult struct { + index int + monitor domain.Monitor + tribe twhelp.Tribe + err error +} + +func (g *Group) GetWithTribes(ctx context.Context, id, serverID string) (domain.GroupWithMonitorsAndTribes, error) { + group, err := g.Get(ctx, id, serverID) + if err != nil { + return domain.GroupWithMonitorsAndTribes{}, err + } + + var wg sync.WaitGroup + ch := make(chan getTribeResult) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + for i, monitor := range group.Monitors { + wg.Add(1) + go func(i int, monitor domain.Monitor) { + defer wg.Done() + + res := getTribeResult{ + index: i, + monitor: monitor, + } + res.tribe, res.err = g.client.GetTribeByID( + ctx, + group.VersionCode, + group.ServerKey, + monitor.TribeID, + ) + ch <- res + }(i, monitor) + } + + go func() { + wg.Wait() + close(ch) + }() + + var firstErr error + monitors := make([]domain.MonitorWithTribe, len(group.Monitors)) + for res := range ch { + if res.err != nil && firstErr == nil { + firstErr = fmt.Errorf("couldn't load tribe (monitorID=%s): %w", res.monitor.ID, res.err) + cancel() + continue + } + if res.err != nil { + continue + } + + monitors[res.index] = domain.MonitorWithTribe{ + Monitor: res.monitor, + Tribe: domain.TribeMeta{ + ID: res.tribe.ID, + Name: res.tribe.Name, + Tag: res.tribe.Tag, + ProfileURL: res.tribe.ProfileURL, + }, + } + } + if firstErr != nil { + return domain.GroupWithMonitorsAndTribes{}, firstErr + } + + return domain.GroupWithMonitorsAndTribes{ + Group: group.Group, + Monitors: monitors, + }, nil +} + func (g *Group) Delete(ctx context.Context, id, serverID string) error { - if err := g.repo.Delete(ctx, id, serverID); err != nil { + // check if group exists + if _, err := g.Get(ctx, id, serverID); err != nil { + return err + } + + if err := g.repo.Delete(ctx, id); err != nil { return fmt.Errorf("GroupRepository.Delete: %w", err) } + return nil } +func (g *Group) Execute(ctx context.Context) ([]domain.EnnoblementNotification, error) { + groups, err := g.repo.List(ctx, domain.ListGroupsParams{ + EnabledNotifications: domain.NullBool{ + Bool: true, + Valid: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("GroupRepository.List: %w", err) + } + + res, err := g.listEnnoblements(ctx, groups) + if err != nil { + return nil, err + } + + return ennoblementNotificationBuilder{ + groups: groups, + ennoblements: res, + }.build(), nil +} + +type listEnnoblementsSingleResult struct { + versionCode string + serverKey string + ennoblements []twhelp.Ennoblement +} + +type listEnnoblementsResult []listEnnoblementsSingleResult + +func (r listEnnoblementsResult) find(versionCode, serverKey string) []twhelp.Ennoblement { + for _, res := range r { + if res.serverKey == serverKey && res.versionCode == versionCode { + return res.ennoblements + } + } + return nil +} + +func (g *Group) listEnnoblements(ctx context.Context, groups []domain.GroupWithMonitors) (listEnnoblementsResult, error) { + g.ennoblementsMu.Lock() + defer g.ennoblementsMu.Unlock() + + var wg sync.WaitGroup + ch := make(chan listEnnoblementsSingleResult) + ennoblementsSince := g.ennoblementsSince + skip := make(map[string]struct{}, len(ennoblementsSince)) + + for _, group := range groups { + key := g.buildEnnoblementsSinceKey(group.VersionCode, group.ServerKey) + + if _, ok := skip[key]; ok { + continue + } + skip[key] = struct{}{} + + since := ennoblementsSince[key] + if since.IsZero() { + since = time.Now().Add(-1 * time.Minute) + } + + wg.Add(1) + go func(group domain.GroupWithMonitors, since time.Time) { + defer wg.Done() + + ennoblements, err := g.client.ListEnnoblements( + ctx, + group.VersionCode, + group.ServerKey, + twhelp.ListEnnoblementsQueryParams{ + Since: since, + Sort: []twhelp.ListEnnoblementsSort{twhelp.ListEnnoblementsSortCreatedAtASC}, + }, + ) + if err != nil { + g.logger.Warn( + "couldn't list ennoblements", + zap.String("versionCode", group.VersionCode), + zap.String("serverKey", group.ServerKey), + zap.Error(err), + ) + return + } + ch <- listEnnoblementsSingleResult{ + versionCode: group.VersionCode, + serverKey: group.ServerKey, + ennoblements: ennoblements, + } + }(group, since) + } + + go func() { + wg.Wait() + close(ch) + }() + + // reinitialize ennoblementsSince + g.ennoblementsSince = make(map[string]time.Time) + + results := make(listEnnoblementsResult, 0, len(skip)) + for res := range ch { + key := g.buildEnnoblementsSinceKey(res.versionCode, res.serverKey) + if l := len(res.ennoblements); l > 0 { + g.ennoblementsSince[key] = res.ennoblements[l-1].CreatedAt.Add(time.Second) + } else { + g.ennoblementsSince[key] = ennoblementsSince[key] + } + results = append(results, res) + } + + return results, nil +} + +func (g *Group) buildEnnoblementsSinceKey(versionCode, serverKey string) string { + return versionCode + ":" + serverKey +} + func (g *Group) CleanUp(ctx context.Context) error { if err := g.deleteAllWithDisabledNotifications(ctx); err != nil { return err @@ -175,7 +473,7 @@ func (g *Group) deleteAllWithDisabledNotifications(ctx context.Context) error { CreatedAtLTE: time.Now().Add(-24 * time.Hour), }) if err != nil { - return fmt.Errorf("GroupRepository.List: %w", err) + return fmt.Errorf("couldn't find groups with disabled notifications: %w", err) } ids := make([]string, 0, len(groups)) @@ -184,7 +482,7 @@ func (g *Group) deleteAllWithDisabledNotifications(ctx context.Context) error { } if err = g.repo.DeleteMany(ctx, ids...); err != nil { - return fmt.Errorf("GroupRepository.DeleteMany: %w", err) + return fmt.Errorf("couldn't delete groups with disabled notifications: %w", err) } return nil @@ -204,7 +502,7 @@ func (g *Group) deleteAllWithClosedTWServers(ctx context.Context) error { }, }) if err != nil { - g.logger.Warn("failed to fetch closed servers", zap.Error(err), zap.String("versionCode", v.Code)) + g.logger.Warn("couldn't list closed servers", zap.Error(err), zap.String("versionCode", v.Code)) continue } @@ -225,7 +523,7 @@ func (g *Group) deleteAllWithClosedTWServers(ctx context.Context) error { groups, err := g.repo.List(ctx, params) if err != nil { - g.logger.Warn("failed to list groups", zap.Error(err), zap.String("versionCode", v.Code)) + g.logger.Warn("couldn't list groups", zap.Error(err), zap.String("versionCode", v.Code)) continue } @@ -239,11 +537,7 @@ func (g *Group) deleteAllWithClosedTWServers(ctx context.Context) error { } if err = g.repo.DeleteMany(ctx, ids...); err != nil { - g.logger.Warn( - "failed to delete groups", - zap.Error(err), - zap.String("versionCode", v.Code), - ) + g.logger.Warn("couldn't delete groups", zap.Error(err), zap.String("versionCode", v.Code)) continue } } diff --git a/internal/service/group_test.go b/internal/service/group_test.go index dc72194..e941324 100644 --- a/internal/service/group_test.go +++ b/internal/service/group_test.go @@ -2,6 +2,7 @@ package service_test import ( "context" + "errors" "fmt" "testing" "time" @@ -19,35 +20,40 @@ import ( func TestGroup_Create(t *testing.T) { t.Parallel() - params, err := domain.NewCreateGroupParams( - "592292203234328587", - "en", - "en113", - "1234", - "1235", - true, - true, - ) - require.NoError(t, err) + newParams := func(tb testing.TB) domain.CreateGroupParams { + tb.Helper() + params, err := domain.NewCreateGroupParams( + "592292203234328587", + "en", + "en113", + "1234", + "1235", + true, + true, + ) + require.NoError(t, err) + return params + } t.Run("OK", func(t *testing.T) { t.Parallel() repo := &mock.FakeGroupRepository{} - repo.CreateCalls(func(_ context.Context, p domain.CreateGroupParams) (domain.Group, error) { - return domain.Group{ - ID: uuid.NewString(), - ServerID: p.ServerID(), - ChannelGains: p.ChannelGains(), - ChannelLosses: p.ChannelLosses(), - Barbarians: p.Barbarians(), - Internals: p.Internals(), - ServerKey: p.ServerKey(), - VersionCode: p.VersionCode(), - CreatedAt: time.Now(), + repo.CreateCalls(func(_ context.Context, p domain.CreateGroupParams) (domain.GroupWithMonitors, error) { + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: p.ServerID(), + ChannelGains: p.ChannelGains(), + ChannelLosses: p.ChannelLosses(), + Barbarians: p.Barbarians(), + Internals: p.Internals(), + ServerKey: p.ServerKey(), + VersionCode: p.VersionCode(), + CreatedAt: time.Now(), + }, }, nil }) - repo.ListReturns(nil, nil) client := &mock.FakeTWHelpClient{} client.GetServerCalls(func(_ context.Context, _ string, server string) (twhelp.Server, error) { @@ -58,7 +64,9 @@ func TestGroup_Create(t *testing.T) { }, nil }) - g, err := service.NewGroup(repo, client, zap.NewNop(), 1).Create(context.Background(), params) + params := newParams(t) + + g, err := service.NewGroup(repo, client, zap.NewNop(), 1, 1).Create(context.Background(), params) assert.NoError(t, err) assert.NotEmpty(t, g.ID) assert.Equal(t, params.ServerID(), g.ServerID) @@ -77,9 +85,9 @@ func TestGroup_Create(t *testing.T) { const maxGroupsPerServer = 10 repo := &mock.FakeGroupRepository{} - repo.ListReturns(make([]domain.Group, maxGroupsPerServer), nil) + repo.ListReturns(make([]domain.GroupWithMonitors, maxGroupsPerServer), nil) - g, err := service.NewGroup(repo, nil, zap.NewNop(), maxGroupsPerServer).Create(context.Background(), params) + g, err := service.NewGroup(repo, nil, zap.NewNop(), maxGroupsPerServer, 1).Create(context.Background(), newParams(t)) assert.ErrorIs(t, err, domain.GroupLimitReachedError{ Current: maxGroupsPerServer, Limit: maxGroupsPerServer, @@ -91,7 +99,6 @@ func TestGroup_Create(t *testing.T) { t.Parallel() repo := &mock.FakeGroupRepository{} - repo.ListReturns(nil, nil) client := &mock.FakeTWHelpClient{} client.GetServerCalls(func(_ context.Context, _ string, _ string) (twhelp.Server, error) { @@ -101,7 +108,9 @@ func TestGroup_Create(t *testing.T) { } }) - g, err := service.NewGroup(repo, client, zap.NewNop(), 1).Create(context.Background(), params) + params := newParams(t) + + g, err := service.NewGroup(repo, client, zap.NewNop(), 1, 1).Create(context.Background(), params) assert.ErrorIs(t, err, domain.ServerDoesNotExistError{ VersionCode: params.VersionCode(), Key: params.ServerKey(), @@ -113,7 +122,6 @@ func TestGroup_Create(t *testing.T) { t.Parallel() repo := &mock.FakeGroupRepository{} - repo.ListReturns(nil, nil) client := &mock.FakeTWHelpClient{} client.GetServerCalls(func(ctx context.Context, _ string, server string) (twhelp.Server, error) { @@ -124,7 +132,9 @@ func TestGroup_Create(t *testing.T) { }, nil }) - g, err := service.NewGroup(repo, client, zap.NewNop(), 1).Create(context.Background(), params) + params := newParams(t) + + g, err := service.NewGroup(repo, client, zap.NewNop(), 1, 1).Create(context.Background(), params) assert.ErrorIs(t, err, domain.ServerIsClosedError{ VersionCode: params.VersionCode(), Key: params.ServerKey(), @@ -133,86 +143,1483 @@ func TestGroup_Create(t *testing.T) { }) } -func TestGroup_CleanUp(t *testing.T) { +func TestGroup_AddMonitor(t *testing.T) { t.Parallel() + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + repo.AddMonitorCalls(func(ctx context.Context, id string, tribeID int64) (domain.GroupWithMonitors, error) { + group.Monitors = append(group.Monitors, domain.Monitor{ + ID: uuid.NewString(), + GroupID: id, + TribeID: tribeID, + }) + return group, nil + }) + + client := &mock.FakeTWHelpClient{} + tribe := twhelp.Tribe{ + ID: 1234, + Tag: "*TAG*", + Name: "Name", + ProfileURL: "Profile", + DeletedAt: time.Time{}, + } + client.ListTribesReturns([]twhelp.Tribe{tribe}, nil) + + g, err := service. + NewGroup(repo, client, zap.NewNop(), 1, 1). + AddMonitor(context.Background(), group.ID, group.ServerID, tribe.Tag) + assert.NoError(t, err) + require.Len(t, g.Monitors, 1) + assert.NotZero(t, g.Monitors[0].ID) + assert.Equal(t, group.ID, g.Monitors[0].GroupID) + assert.Equal(t, tribe.ID, g.Monitors[0].TribeID) + }) + + t.Run("ERR: group doesn't exist", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + serverID: uuid.NewString(), + group: group, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + AddMonitor(context.Background(), group.ID, uuid.NewString(), "*TAG*") + assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) + + t.Run("ERR: monitor limit has been reached", func(t *testing.T) { + t.Parallel() + + const maxMonitorsPerGroup = 10 + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + Monitors: make([]domain.Monitor, maxMonitorsPerGroup), + } + repo.GetReturns(group, nil) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, maxMonitorsPerGroup). + AddMonitor(context.Background(), group.ID, group.ServerID, "*TAG*") + assert.ErrorIs(t, err, domain.MonitorLimitReachedError{ + Current: len(group.Monitors), + Limit: maxMonitorsPerGroup, + }) + assert.Zero(t, g) + }) + + t.Run("ERR: tribe doesn't exist", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + + client := &mock.FakeTWHelpClient{} + client.ListTribesReturns(nil, nil) + + tribeTag := "*TAG*" + g, err := service. + NewGroup(repo, client, zap.NewNop(), 1, 1). + AddMonitor(context.Background(), group.ID, group.ServerID, tribeTag) + assert.ErrorIs(t, err, domain.TribeDoesNotExistError{ + Tag: tribeTag, + }) + assert.Zero(t, g) + }) +} + +func TestGroup_DeleteMonitor(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + groupID := uuid.NewString() + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: groupID, + ServerID: uuid.NewString(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: 1234, + }, + { + ID: uuid.NewString(), + TribeID: 1237, + }, + }, + } + repo.GetReturns(group, nil) + repo.DeleteMonitorsCalls(func(ctx context.Context, id string, monitorIDs ...string) (domain.GroupWithMonitors, error) { + monitors := make([]domain.Monitor, 0, len(group.Monitors)-1) + for _, m := range group.Monitors { + var skip bool + for _, monitorID := range monitorIDs { + if m.ID == monitorID { + skip = true + break + } + } + if skip { + continue + } + monitors = append(monitors, m) + } + return domain.GroupWithMonitors{ + Monitors: monitors, + }, nil + }) + + client := &mock.FakeTWHelpClient{} + tribe := twhelp.Tribe{ + ID: group.Monitors[0].TribeID, + Tag: "*TAG*", + } + client.ListTribesReturns([]twhelp.Tribe{tribe}, nil) + + g, err := service. + NewGroup(repo, client, zap.NewNop(), 1, 1). + DeleteMonitor(context.Background(), group.ID, group.ServerID, tribe.Tag) + assert.NoError(t, err) + assert.Len(t, g.Monitors, len(group.Monitors)-1) + assert.NotContains(t, g.Monitors, group.Monitors[0]) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + }, + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + DeleteMonitor(context.Background(), group.ID, tt.serverID, group.Monitors[0].ID) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) + + t.Run("ERR: tribe not found", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + groupID := uuid.NewString() + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: groupID, + ServerID: uuid.NewString(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: 1234, + }, + { + ID: uuid.NewString(), + TribeID: 1237, + }, + }, + } + repo.GetReturns(group, nil) + repo.DeleteMonitorsCalls(func(ctx context.Context, id string, monitorIDs ...string) (domain.GroupWithMonitors, error) { + monitors := make([]domain.Monitor, 0, len(group.Monitors)-1) + for _, m := range group.Monitors { + var skip bool + for _, monitorID := range monitorIDs { + if m.ID == monitorID { + skip = true + break + } + } + if skip { + continue + } + monitors = append(monitors, m) + } + return domain.GroupWithMonitors{ + Monitors: monitors, + }, nil + }) + + client := &mock.FakeTWHelpClient{} + + tag := "*X*" + g, err := service. + NewGroup(repo, client, zap.NewNop(), 1, 1). + DeleteMonitor(context.Background(), group.ID, group.ServerID, tag) + assert.ErrorIs(t, err, domain.TribeNotFoundError{ + Tag: tag, + }) + assert.Zero(t, g) + }) +} + +func TestGroup_SetChannelGains(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + repo.UpdateCalls(func(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: id, + ChannelGains: params.ChannelGains.String, + }, + }, nil + }) + + channelID := uuid.NewString() + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetChannelGains(context.Background(), group.ID, group.ServerID, channelID) + assert.NoError(t, err) + assert.Equal(t, group.ID, g.ID) + assert.Equal(t, channelID, g.ChannelGains) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetChannelGains(context.Background(), group.ID, tt.serverID, uuid.NewString()) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) +} + +func TestGroup_SetChannelLosses(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + repo.UpdateCalls(func(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: id, + ChannelLosses: params.ChannelLosses.String, + }, + }, nil + }) + + channelID := uuid.NewString() + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetChannelLosses(context.Background(), group.ID, group.ServerID, channelID) + assert.NoError(t, err) + assert.Equal(t, group.ID, g.ID) + assert.Equal(t, channelID, g.ChannelLosses) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetChannelLosses(context.Background(), group.ID, tt.serverID, uuid.NewString()) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) +} + +func TestGroup_SetInternals(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + repo.UpdateCalls(func(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: id, + Internals: params.Internals.Bool, + }, + }, nil + }) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetInternals(context.Background(), group.ID, group.ServerID, true) + assert.NoError(t, err) + assert.Equal(t, group.ID, g.ID) + assert.True(t, g.Internals) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetInternals(context.Background(), group.ID, tt.serverID, true) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) +} + +func TestGroup_SetBarbarians(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + repo.UpdateCalls(func(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) { + return domain.GroupWithMonitors{ + Group: domain.Group{ + ID: id, + Barbarians: params.Barbarians.Bool, + }, + }, nil + }) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetBarbarians(context.Background(), group.ID, group.ServerID, true) + assert.NoError(t, err) + assert.Equal(t, group.ID, g.ID) + assert.True(t, g.Barbarians) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + SetBarbarians(context.Background(), group.ID, tt.serverID, true) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) +} + +func TestGroup_Get(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + Get(context.Background(), group.ID, group.ServerID) + assert.NoError(t, err) + assert.Equal(t, group, g) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + Get(context.Background(), group.ID, tt.serverID) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) +} + +func TestGroup_GetWithTribes(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + VersionCode: "pl", + ServerKey: "pl181", + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: 115, + }, + { + ID: uuid.NewString(), + TribeID: 130, + }, + { + ID: uuid.NewString(), + TribeID: 11, + }, + }, + } + repo.GetReturns(group, nil) + + client := &mock.FakeTWHelpClient{} + client.GetTribeByIDCalls(func(ctx context.Context, versionCode string, serverKey string, id int64) (twhelp.Tribe, error) { + return twhelp.Tribe{ + ID: id, + Tag: fmt.Sprintf("*%d*", id), + Name: fmt.Sprintf("name %d", id), + ProfileURL: fmt.Sprintf("profile-%d", id), + }, nil + }) + + g, err := service. + NewGroup(repo, client, zap.NewNop(), 1, 1). + GetWithTribes(context.Background(), group.ID, group.ServerID) + assert.NoError(t, err) + assert.Equal(t, group.Group, g.Group) + assert.Len(t, g.Monitors, len(group.Monitors)) + for _, m := range g.Monitors { + assert.NotZero(t, m.Tribe) + } + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + g, err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + GetWithTribes(context.Background(), group.ID, tt.serverID) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + assert.Zero(t, g) + }) + } + }) + + t.Run("ERR: one of requests failed", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + VersionCode: "pl", + ServerKey: "pl181", + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: 115, + }, + { + ID: uuid.NewString(), + TribeID: 130, + }, + { + ID: uuid.NewString(), + TribeID: 11, + }, + }, + } + repo.GetReturns(group, nil) + + client := &mock.FakeTWHelpClient{} + expectedErr := errors.New("err") + client.GetTribeByIDCalls(func(ctx context.Context, versionCode string, serverKey string, id int64) (twhelp.Tribe, error) { + if id == 115 { + return twhelp.Tribe{}, expectedErr + } + + <-ctx.Done() + return twhelp.Tribe{}, ctx.Err() + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + g, err := service. + NewGroup(repo, client, zap.NewNop(), 1, 1). + GetWithTribes(ctx, group.ID, group.ServerID) + assert.ErrorIs(t, err, expectedErr) + assert.Zero(t, g) + }) +} + +func TestGroup_Delete(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + repo.GetReturns(group, nil) + + assert.NoError(t, service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + Delete(context.Background(), group.ID, group.ServerID)) + }) + + t.Run("ERR: group not found", func(t *testing.T) { + t.Parallel() + + group := domain.GroupWithMonitors{ + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + }, + } + + tests := []struct { + name string + group domain.GroupWithMonitors + serverID string + err error + }{ + { + name: "repository returned error", + serverID: group.ServerID, + err: domain.GroupNotFoundError{ + ID: group.ID, + }, + }, + { + name: "incorrect server id", + group: group, + serverID: uuid.NewString(), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := &mock.FakeGroupRepository{} + repo.GetReturns(tt.group, tt.err) + + err := service. + NewGroup(repo, nil, zap.NewNop(), 1, 1). + Delete(context.Background(), group.ID, tt.serverID) + assert.ErrorIs(t, err, domain.GroupNotFoundError{ + ID: group.ID, + }) + }) + } + }) +} + +func TestGroup_Execute(t *testing.T) { + t.Parallel() + + client := &mock.FakeTWHelpClient{} + tribes := map[string][]twhelp.TribeMeta{ + "pl:pl181": { + { + ID: 1, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 2, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 3, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + }, + "en:en130": { + { + ID: 100, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 101, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + }, + "pl:pl180": { + { + ID: 200, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + }, + "de:de200": { + { + ID: 300, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 301, + Name: uuid.NewString(), + Tag: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + }, + } + players := map[string][]twhelp.PlayerMeta{ + "pl:pl181": { + { + ID: 1, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["pl:pl181"][0], + Valid: true, + }, + }, + { + ID: 2, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["pl:pl181"][1], + Valid: true, + }, + }, + { + ID: 3, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["pl:pl181"][2], + Valid: true, + }, + }, + { + ID: 4, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{}, + }, + }, + "en:en130": { + { + ID: 100, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["en:en130"][0], + Valid: true, + }, + }, + { + ID: 101, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["en:en130"][1], + Valid: true, + }, + }, + { + ID: 102, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{}, + }, + }, + "pl:pl180": { + { + ID: 200, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["pl:pl180"][0], + Valid: true, + }, + }, + }, + "de:de200": { + { + ID: 300, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["de:de200"][0], + Valid: true, + }, + }, + { + ID: 301, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["de:de200"][0], + Valid: true, + }, + }, + { + ID: 302, + Name: uuid.NewString(), + ProfileURL: uuid.NewString(), + Tribe: twhelp.NullTribeMeta{ + Tribe: tribes["de:de200"][1], + Valid: true, + }, + }, + }, + } + villages := map[string][]twhelp.VillageMeta{ + "pl:pl181": { + { + ID: 1, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][1], + Valid: true, + }, + }, + { + ID: 2, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][0], + Valid: true, + }, + }, + { + ID: 3, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][2], + Valid: true, + }, + }, + { + ID: 4, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 5, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][0], + Valid: true, + }, + }, + }, + "en:en130": { + { + ID: 100, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 101, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["en:en130"][0], + Valid: true, + }, + }, + { + ID: 102, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["en:en130"][2], + Valid: true, + }, + }, + }, + "pl:pl180": { + { + ID: 200, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + }, + "de:de200": { + { + ID: 300, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + }, + { + ID: 301, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["de:de200"][1], + Valid: true, + }, + }, + { + ID: 302, + FullName: uuid.NewString(), + ProfileURL: uuid.NewString(), + Player: twhelp.NullPlayerMeta{ + Player: players["de:de200"][2], + Valid: true, + }, + }, + }, + } + ennoblements := map[string][]twhelp.Ennoblement{ + "pl:pl181": { + { + ID: 1, + Village: villages["pl:pl181"][0], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 2, // self conquer, should be skipped + Village: villages["pl:pl181"][1], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-4 * time.Minute), + }, + { + ID: 3, // internal, should be skipped (internals disabled) + Village: villages["pl:pl181"][2], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-3 * time.Minute), + }, + { + ID: 4, // barbarian, shouldn't be skipped (barbarians enabled) + Village: villages["pl:pl181"][3], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 5, // disabled notifications about gains, should be skipped + Village: villages["pl:pl181"][4], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][1], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 6, // disabled notifications about losses, should be skipped + Village: villages["pl:pl181"][4], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl181"][3], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + }, + "en:en130": { + { + ID: 100, // no monitor for these tribes, should be skipped + Village: villages["en:en130"][0], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["en:en130"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 101, // no monitor for these tribes, should be skipped + Village: villages["en:en130"][1], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["en:en130"][2], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 102, + Village: villages["en:en130"][2], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["en:en130"][1], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + }, + "pl:pl180": { + { + ID: 200, // api error, should be skipped + Village: villages["pl:pl180"][0], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["pl:pl180"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + }, + "de:de200": { + { + ID: 300, // barbarian, should be skipped (barbarians disabled) + Village: villages["de:de200"][0], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["de:de200"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 301, // internal, shouldn't be skipped (internals enabled) + Village: villages["de:de200"][1], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["de:de200"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + { + ID: 302, // internal, shouldn't be skipped (internals enabled) + Village: villages["de:de200"][2], + NewOwner: twhelp.NullPlayerMeta{ + Player: players["de:de200"][0], + Valid: true, + }, + CreatedAt: time.Now().Add(-5 * time.Minute), + }, + }, + } + client.ListEnnoblementsCalls( + func( + ctx context.Context, + version string, + server string, + _ twhelp.ListEnnoblementsQueryParams, + ) ([]twhelp.Ennoblement, error) { + if version == "pl" && server == "pl180" { + return nil, errors.New("random error") + } + return ennoblements[version+":"+server], nil + }, + ) + repo := &mock.FakeGroupRepository{} - groupsWithDisabledNotifications := []domain.Group{ + groups := []domain.GroupWithMonitors{ { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: "", - ChannelLosses: "", - Internals: true, - Barbarians: true, - ServerKey: "pl181", - VersionCode: "pl", - CreatedAt: time.Now().Add(-48 * time.Hour), + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: "", + Barbarians: true, + ServerKey: "pl181", + VersionCode: "pl", + CreatedAt: time.Now(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: tribes["pl:pl181"][0].ID, + CreatedAt: time.Now(), + }, + { + ID: uuid.NewString(), + TribeID: tribes["pl:pl181"][2].ID, + CreatedAt: time.Now(), + }, + }, }, { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: "", - ChannelLosses: "", - Internals: true, - Barbarians: true, - ServerKey: "pl181", - VersionCode: "pl", - CreatedAt: time.Now().Add(-45 * time.Hour), + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: "", + ChannelLosses: uuid.NewString(), + ServerKey: "pl181", + VersionCode: "pl", + CreatedAt: time.Now(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: tribes["pl:pl181"][1].ID, + CreatedAt: time.Now(), + }, + }, + }, + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: uuid.NewString(), + ServerKey: "en130", + VersionCode: "en", + CreatedAt: time.Now(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: tribes["en:en130"][1].ID, + CreatedAt: time.Now(), + }, + }, + }, + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: uuid.NewString(), + Barbarians: true, + ServerKey: "pl180", + VersionCode: "pl", + CreatedAt: time.Now(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: tribes["pl:pl180"][0].ID, + CreatedAt: time.Now(), + }, + }, + }, + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: uuid.NewString(), + Barbarians: false, + Internals: true, + ServerKey: "de200", + VersionCode: "de", + CreatedAt: time.Now(), + }, + Monitors: []domain.Monitor{ + { + ID: uuid.NewString(), + TribeID: tribes["de:de200"][0].ID, + CreatedAt: time.Now(), + }, + { + ID: uuid.NewString(), + TribeID: tribes["de:de200"][1].ID, + CreatedAt: time.Now(), + }, + }, }, } - repo.ListReturnsOnCall(0, groupsWithDisabledNotifications, nil) - groupsPL := []domain.Group{ - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: "", - Internals: true, - Barbarians: true, - ServerKey: "pl181", - VersionCode: "pl", - CreatedAt: time.Now().Add(-120 * time.Hour), - }, - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: uuid.NewString(), - Internals: true, - Barbarians: true, - ServerKey: "pl181", - VersionCode: "pl", - CreatedAt: time.Now().Add(-11 * time.Hour), - }, + repo.ListReturns(groups, nil) + + notifications, err := service.NewGroup(repo, client, zap.NewNop(), 1, 1). + Execute(context.Background()) + assert.NoError(t, err) + expectedNotifications := []domain.EnnoblementNotification{ + newNotification(domain.EnnoblementNotificationTypeGain, groups[0].ServerID, groups[0].ChannelGains, ennoblements["pl:pl181"][0]), + newNotification(domain.EnnoblementNotificationTypeGain, groups[0].ServerID, groups[0].ChannelGains, ennoblements["pl:pl181"][3]), + newNotification(domain.EnnoblementNotificationTypeLoss, groups[1].ServerID, groups[1].ChannelLosses, ennoblements["pl:pl181"][0]), + newNotification(domain.EnnoblementNotificationTypeGain, groups[2].ServerID, groups[2].ChannelGains, ennoblements["en:en130"][2]), + newNotification(domain.EnnoblementNotificationTypeGain, groups[4].ServerID, groups[4].ChannelGains, ennoblements["de:de200"][1]), + newNotification(domain.EnnoblementNotificationTypeGain, groups[4].ServerID, groups[4].ChannelGains, ennoblements["de:de200"][2]), } - repo.ListReturnsOnCall(1, groupsPL, nil) - groupsUK := []domain.Group{ - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: uuid.NewString(), - Internals: true, - Barbarians: true, - ServerKey: "uk51", - VersionCode: "uk", - CreatedAt: time.Now().Add(-150 * time.Hour), - }, - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: uuid.NewString(), - Internals: true, - Barbarians: true, - ServerKey: "uk52", - VersionCode: "uk", - CreatedAt: time.Now().Add(-200 * time.Hour), - }, + assert.Len(t, notifications, len(expectedNotifications)) + for _, n := range expectedNotifications { + assert.Contains(t, notifications, n) } - repo.ListReturnsOnCall(2, groupsUK, nil) - repo.DeleteManyReturns(nil) +} + +func TestGroup_CleanUp(t *testing.T) { + t.Parallel() client := &mock.FakeTWHelpClient{} versions := []twhelp.Version{ @@ -236,37 +1643,138 @@ func TestGroup_CleanUp(t *testing.T) { }, } client.ListVersionsReturns(versions, nil) - serversPL := []twhelp.Server{ - { - Key: "pl181", - URL: "https://pl181.plemiona.pl", - Open: false, + serversByVersion := map[string][]twhelp.Server{ + versions[0].Code: { // pl + { + Key: "pl181", + URL: "https://pl181.plemiona.pl", + Open: false, + }, + }, + versions[1].Code: { // uk + { + Key: "uk51", + URL: "https://uk51.tribalwars.co.uk", + Open: false, + }, + { + Key: "uk52", + URL: "https://uk52.tribalwars.co.uk", + Open: false, + }, }, } - client.ListServersReturnsOnCall(0, serversPL, nil) - serversUK := []twhelp.Server{ + client.ListServersCalls(func(ctx context.Context, version string, params twhelp.ListServersQueryParams) ([]twhelp.Server, error) { + return serversByVersion[version], nil + }) + + repo := &mock.FakeGroupRepository{} + groupsWithDisabledNotifications := []domain.GroupWithMonitors{ { - Key: "uk51", - URL: "https://uk51.tribalwars.co.uk", - Open: false, + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: "", + ChannelLosses: "", + Internals: true, + Barbarians: true, + ServerKey: "pl181", + VersionCode: versions[0].Code, + CreatedAt: time.Now().Add(-48 * time.Hour), + }, }, { - Key: "uk52", - URL: "https://uk52.tribalwars.co.uk", - Open: false, + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: "", + ChannelLosses: "", + Internals: true, + Barbarians: true, + ServerKey: "pl181", + VersionCode: versions[0].Code, + CreatedAt: time.Now().Add(-45 * time.Hour), + }, }, } - client.ListServersReturnsOnCall(1, serversUK, nil) - client.ListServersReturnsOnCall(1, serversUK, nil) - client.ListServersReturnsOnCall(2, nil, nil) // Turkey + groupsPL := []domain.GroupWithMonitors{ + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: "", + Internals: true, + Barbarians: true, + ServerKey: "pl181", + VersionCode: versions[0].Code, + CreatedAt: time.Now().Add(-120 * time.Hour), + }, + }, + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: uuid.NewString(), + Internals: true, + Barbarians: true, + ServerKey: "pl181", + VersionCode: versions[0].Code, + CreatedAt: time.Now().Add(-11 * time.Hour), + }, + }, + } + groupsUK := []domain.GroupWithMonitors{ + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: uuid.NewString(), + Internals: true, + Barbarians: true, + ServerKey: "uk51", + VersionCode: versions[1].Code, + CreatedAt: time.Now().Add(-150 * time.Hour), + }, + }, + { + Group: domain.Group{ + ID: uuid.NewString(), + ServerID: uuid.NewString(), + ChannelGains: uuid.NewString(), + ChannelLosses: uuid.NewString(), + Internals: true, + Barbarians: true, + ServerKey: "uk52", + VersionCode: versions[1].Code, + CreatedAt: time.Now().Add(-200 * time.Hour), + }, + }, + } + repo.ListCalls(func(ctx context.Context, params domain.ListGroupsParams) ([]domain.GroupWithMonitors, error) { + if params.EnabledNotifications.Valid && !params.EnabledNotifications.Bool { + return groupsWithDisabledNotifications, nil + } - assert.NoError(t, service.NewGroup(repo, client, zap.NewNop(), 1).CleanUp(context.Background())) + if params.VersionCode.String == versions[0].Code { + return groupsPL, nil + } + + if params.VersionCode.String == versions[1].Code { + return groupsUK, nil + } + + return nil, errors.New("unexpected call") + }) + + assert.NoError(t, service.NewGroup(repo, client, zap.NewNop(), 1, 1).CleanUp(context.Background())) - require.Equal(t, 3, repo.ListCallCount()) require.Equal(t, 3, repo.DeleteManyCallCount()) for _, tt := range []struct { i int - groups []domain.Group + groups []domain.GroupWithMonitors }{ { i: 0, @@ -287,7 +1795,45 @@ func TestGroup_CleanUp(t *testing.T) { assert.Equal(t, tt.groups[i].ID, id) } } - - require.Equal(t, 1, client.ListVersionsCallCount()) - require.Equal(t, 3, client.ListServersCallCount()) +} + +func newNotification(typ domain.EnnoblementNotificationType, serverID, channelID string, e twhelp.Ennoblement) domain.EnnoblementNotification { + return domain.EnnoblementNotification{ + Type: typ, + ServerID: serverID, + ChannelID: channelID, + Ennoblement: domain.Ennoblement{ + ID: e.ID, + Village: domain.VillageMeta{ + ID: e.Village.ID, + FullName: e.Village.FullName, + ProfileURL: e.Village.ProfileURL, + Player: domain.NullPlayerMeta{ + Player: domain.PlayerMeta{ + ID: e.Village.Player.Player.ID, + Name: e.Village.Player.Player.Name, + ProfileURL: e.Village.Player.Player.ProfileURL, + Tribe: domain.NullTribeMeta{ + Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe), + Valid: e.Village.Player.Player.Tribe.Valid, + }, + }, + Valid: e.Village.Player.Valid, + }, + }, + NewOwner: domain.NullPlayerMeta{ + Player: domain.PlayerMeta{ + ID: e.NewOwner.Player.ID, + Name: e.NewOwner.Player.Name, + ProfileURL: e.NewOwner.Player.ProfileURL, + Tribe: domain.NullTribeMeta{ + Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe), + Valid: e.NewOwner.Player.Tribe.Valid, + }, + }, + Valid: e.NewOwner.Valid, + }, + CreatedAt: e.CreatedAt, + }, + } } diff --git a/internal/service/monitor.go b/internal/service/monitor.go deleted file mode 100644 index 342c17c..0000000 --- a/internal/service/monitor.go +++ /dev/null @@ -1,474 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "sync" - "time" - - "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" - "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" - "go.uber.org/zap" -) - -//counterfeiter:generate -o internal/mock/monitor_repository.gen.go . MonitorRepository -type MonitorRepository interface { - Create(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) - List(ctx context.Context, groupID string) ([]domain.Monitor, error) - Get(ctx context.Context, id string) (domain.Monitor, error) - Delete(ctx context.Context, id string) error -} - -//counterfeiter:generate -o internal/mock/group_reader.gen.go . GroupReader -type GroupReader interface { - Get(ctx context.Context, id, serverID string) (domain.Group, error) - List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) -} - -type Monitor struct { - repo MonitorRepository - client TWHelpClient - groupSvc GroupReader - maxMonitorsPerGroup int - logger *zap.Logger - ennoblementsMu sync.Mutex // ennoblementsMu is used by Monitor.fetchEnnoblements - ennoblementsSince map[string]time.Time // ennoblementsSince is used by Monitor.fetchEnnoblements -} - -func NewMonitor( - repo MonitorRepository, - groupSvc GroupReader, - client TWHelpClient, - logger *zap.Logger, - maxMonitorsPerGroup int, -) *Monitor { - return &Monitor{ - repo: repo, - client: client, - groupSvc: groupSvc, - logger: logger, - maxMonitorsPerGroup: maxMonitorsPerGroup, - ennoblementsSince: make(map[string]time.Time), - } -} - -func (m *Monitor) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) { - // check if group exists - group, err := m.groupSvc.Get(ctx, groupID, serverID) - if err != nil { - if errors.Is(err, domain.GroupNotFoundError{ID: groupID}) { - return domain.Monitor{}, domain.GroupDoesNotExistError{ID: groupID} - } - return domain.Monitor{}, fmt.Errorf("GroupService.Get: %w", err) - } - - // check limit - monitors, err := m.repo.List(ctx, group.ID) - if err != nil { - return domain.Monitor{}, fmt.Errorf("MonitorRepository.List: %w", err) - } - - if len(monitors) >= m.maxMonitorsPerGroup { - return domain.Monitor{}, domain.MonitorLimitReachedError{ - Current: len(monitors), - Limit: m.maxMonitorsPerGroup, - } - } - - tribes, err := m.client.ListTribes(ctx, group.VersionCode, group.ServerKey, twhelp.ListTribesQueryParams{ - Limit: 1, - Tags: []string{tribeTag}, - Deleted: twhelp.NullBool{ - Valid: true, - Bool: false, - }, - }) - if err != nil { - var apiErr twhelp.APIError - if !errors.As(err, &apiErr) { - return domain.Monitor{}, fmt.Errorf("TWHelpClient.GetServer: %w", err) - } - return domain.Monitor{}, domain.TribeDoesNotExistError{ - Tag: tribeTag, - } - } - if len(tribes) == 0 { - return domain.Monitor{}, domain.TribeDoesNotExistError{ - Tag: tribeTag, - } - } - tribe := tribes[0] - - params, err := domain.NewCreateMonitorParams(group.ID, tribe.ID) - if err != nil { - return domain.Monitor{}, fmt.Errorf("domain.NewCreateMonitorParams: %w", err) - } - - monitor, err := m.repo.Create(ctx, params) - if err != nil { - return domain.Monitor{}, fmt.Errorf("MonitorRepository.Create: %w", err) - } - - return monitor, nil -} - -type getTribeResult struct { - index int - monitor domain.Monitor - tribe twhelp.Tribe - err error -} - -func (m *Monitor) List(ctx context.Context, groupID, serverID string) ([]domain.MonitorWithTribe, error) { - group, err := m.groupSvc.Get(ctx, groupID, serverID) - if err != nil { - return nil, fmt.Errorf("GroupService.Get: %w", err) - } - - monitors, err := m.repo.List(ctx, group.ID) - if err != nil { - return nil, fmt.Errorf("MonitorRepository.Delete: %w", err) - } - - var wg sync.WaitGroup - ch := make(chan getTribeResult) - - for i, monitor := range monitors { - wg.Add(1) - go func(i int, monitor domain.Monitor) { - res := getTribeResult{ - index: i, - monitor: monitor, - } - res.tribe, res.err = m.client.GetTribeByID( - ctx, - group.VersionCode, - group.ServerKey, - monitor.TribeID, - ) - ch <- res - }(i, monitor) - } - - go func() { - wg.Wait() - close(ch) - }() - - var firstErr error - results := make([]domain.MonitorWithTribe, len(monitors)) - for res := range ch { - wg.Done() - - if res.err != nil && firstErr == nil { - firstErr = fmt.Errorf("couldn't load tribe (monitorID=%s): %w", res.monitor.ID, res.err) - continue - } - - results[res.index] = domain.MonitorWithTribe{ - Monitor: res.monitor, - Tribe: domain.TribeMeta{ - ID: res.tribe.ID, - Name: res.tribe.Name, - Tag: res.tribe.Tag, - ProfileURL: res.tribe.ProfileURL, - }, - } - } - if firstErr != nil { - return nil, firstErr - } - - return results, nil -} - -func (m *Monitor) Delete(ctx context.Context, id, serverID string) error { - monitor, err := m.repo.Get(ctx, id) - if err != nil { - return fmt.Errorf("MonitorRepository.Get: %w", err) - } - - _, err = m.groupSvc.Get(ctx, monitor.GroupID, serverID) - if err != nil { - return fmt.Errorf("GroupService.Get: %w", err) - } - - if err = m.repo.Delete(ctx, id); err != nil { - return fmt.Errorf("MonitorRepository.Delete: %w", err) - } - - return nil -} - -func (m *Monitor) Execute(ctx context.Context) ([]domain.EnnoblementNotification, error) { - groups, err := m.groupSvc.List(ctx, domain.ListGroupsParams{ - EnabledNotifications: domain.NullBool{ - Bool: true, - Valid: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("GroupService.List: %w", err) - } - - res, err := m.fetchEnnoblements(ctx, groups) - if err != nil { - return nil, err - } - - //nolint:prealloc - var notifications []domain.EnnoblementNotification - for _, g := range groups { - for _, r := range res { - if g.ServerKey != r.serverKey || g.VersionCode != r.versionCode { - continue - } - - ns, err := m.executeGroup(ctx, g, r.ennoblements) - if err != nil { - m.logger.Warn( - "something went wrong while executing group", - zap.String("group", g.ID), - zap.Error(err), - ) - continue - } - notifications = append(notifications, ns...) - } - } - - return notifications, nil -} - -func (m *Monitor) executeGroup( - ctx context.Context, - g domain.Group, - ennoblements []twhelp.Ennoblement, -) ([]domain.EnnoblementNotification, error) { - monitors, err := m.repo.List(ctx, g.ID) - if err != nil { - return nil, fmt.Errorf("MonitorRepository.List: %w", err) - } - - //nolint:prealloc - var notifications []domain.EnnoblementNotification - for _, e := range ennoblements { - if canSendEnnoblementNotificationTypeGain(g, e, monitors) { - notifications = append(notifications, domain.EnnoblementNotification{ - Type: domain.EnnoblementNotificationTypeGain, - ServerID: g.ServerID, - ChannelID: g.ChannelGains, - Ennoblement: ennoblementToDomainModel(e), - }) - } - - if canSendEnnoblementNotificationTypeLoss(g, e, monitors) { - notifications = append(notifications, domain.EnnoblementNotification{ - Type: domain.EnnoblementNotificationTypeLoss, - ServerID: g.ServerID, - ChannelID: g.ChannelLosses, - Ennoblement: ennoblementToDomainModel(e), - }) - } - } - - return notifications, nil -} - -type listEnnoblementsResult struct { - versionCode string - serverKey string - ennoblements []twhelp.Ennoblement - err error -} - -type fetchEnnoblementsResult struct { - versionCode string - serverKey string - ennoblements []twhelp.Ennoblement -} - -func (m *Monitor) fetchEnnoblements(ctx context.Context, groups []domain.Group) ([]fetchEnnoblementsResult, error) { - m.ennoblementsMu.Lock() - defer m.ennoblementsMu.Unlock() - - var wg sync.WaitGroup - ch := make(chan listEnnoblementsResult) - ennoblementsSince := m.ennoblementsSince - skip := make(map[string]struct{}, len(ennoblementsSince)) - - for _, g := range groups { - key := g.VersionCode + ":" + g.ServerKey - - if _, ok := skip[key]; ok { - continue - } - skip[key] = struct{}{} - - since := ennoblementsSince[key] - if since.IsZero() { - since = time.Now().Add(-1 * time.Minute) - } - - wg.Add(1) - go func(g domain.Group, since time.Time) { - res := listEnnoblementsResult{ - versionCode: g.VersionCode, - serverKey: g.ServerKey, - } - res.ennoblements, res.err = m.client.ListEnnoblements( - ctx, - g.VersionCode, - g.ServerKey, - twhelp.ListEnnoblementsQueryParams{ - Since: since, - Sort: []twhelp.ListEnnoblementsSort{twhelp.ListEnnoblementsSortCreatedAtASC}, - }, - ) - ch <- res - }(g, since) - } - - go func() { - wg.Wait() - close(ch) - }() - - // reinitialize ennoblementsSince - m.ennoblementsSince = make(map[string]time.Time) - - //nolint:prealloc - var results []fetchEnnoblementsResult - for res := range ch { - wg.Done() - - key := res.versionCode + ":" + res.serverKey - if l := len(res.ennoblements); l > 0 { - m.ennoblementsSince[key] = res.ennoblements[l-1].CreatedAt.Add(time.Second) - } else { - m.ennoblementsSince[key] = ennoblementsSince[key] - } - - if res.err != nil { - m.logger.Warn( - "failed to fetch ennoblements", - zap.String("versionCode", res.versionCode), - zap.String("serverKey", res.serverKey), - zap.Error(res.err), - ) - continue - } - - results = append(results, fetchEnnoblementsResult{ - versionCode: res.versionCode, - serverKey: res.serverKey, - ennoblements: res.ennoblements, - }) - } - - return results, nil -} - -func canSendEnnoblementNotificationTypeGain(g domain.Group, e twhelp.Ennoblement, monitors []domain.Monitor) bool { - if g.ChannelGains == "" { - return false - } - - if !g.Barbarians && isBarbarian(e) { - return false - } - - if !g.Internals && isInternal(e, monitors) { - return false - } - - return isGain(e, monitors) -} - -func canSendEnnoblementNotificationTypeLoss(g domain.Group, e twhelp.Ennoblement, monitors []domain.Monitor) bool { - if g.ChannelLosses == "" { - return false - } - - if isInternal(e, monitors) { - return false - } - - return isLoss(e, monitors) -} - -func isInternal(e twhelp.Ennoblement, monitors []domain.Monitor) bool { - var n, o bool - for _, m := range monitors { - if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID { - n = true - } - if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID { - o = true - } - } - return n && o -} - -func isBarbarian(e twhelp.Ennoblement) bool { - return !e.Village.Player.Valid -} - -func isGain(e twhelp.Ennoblement, monitors []domain.Monitor) bool { - var n bool - for _, m := range monitors { - if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID { - n = true - break - } - } - return n && e.NewOwner.Player.ID != e.Village.Player.Player.ID -} - -func isLoss(e twhelp.Ennoblement, monitors []domain.Monitor) bool { - var o bool - for _, m := range monitors { - if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID { - o = true - break - } - } - return o && e.NewOwner.Player.ID != e.Village.Player.Player.ID -} - -func ennoblementToDomainModel(e twhelp.Ennoblement) domain.Ennoblement { - return domain.Ennoblement{ - ID: e.ID, - Village: domain.VillageMeta{ - ID: e.Village.ID, - FullName: e.Village.FullName, - ProfileURL: e.Village.ProfileURL, - Player: domain.NullPlayerMeta{ - Player: domain.PlayerMeta{ - ID: e.Village.Player.Player.ID, - Name: e.Village.Player.Player.Name, - ProfileURL: e.Village.Player.Player.ProfileURL, - Tribe: domain.NullTribeMeta{ - Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe), - Valid: e.Village.Player.Player.Tribe.Valid, - }, - }, - Valid: e.Village.Player.Valid, - }, - }, - NewOwner: domain.NullPlayerMeta{ - Player: domain.PlayerMeta{ - ID: e.NewOwner.Player.ID, - Name: e.NewOwner.Player.Name, - ProfileURL: e.NewOwner.Player.ProfileURL, - Tribe: domain.NullTribeMeta{ - Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe), - Valid: e.NewOwner.Player.Tribe.Valid, - }, - }, - Valid: e.NewOwner.Valid, - }, - CreatedAt: e.CreatedAt, - } -} diff --git a/internal/service/monitor_test.go b/internal/service/monitor_test.go deleted file mode 100644 index 60fe9d4..0000000 --- a/internal/service/monitor_test.go +++ /dev/null @@ -1,811 +0,0 @@ -package service_test - -import ( - "context" - "errors" - "testing" - "time" - - "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" - "gitea.dwysokinski.me/twhelp/dcbot/internal/service" - "gitea.dwysokinski.me/twhelp/dcbot/internal/service/internal/mock" - "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "go.uber.org/zap" -) - -func TestMonitor_Create(t *testing.T) { - t.Parallel() - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - repo := &mock.FakeMonitorRepository{} - repo.ListReturns(nil, nil) - repo.CreateCalls(func(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) { - return domain.Monitor{ - ID: uuid.NewString(), - TribeID: params.TribeID(), - GroupID: params.GroupID(), - CreatedAt: time.Now(), - }, nil - }) - - groupSvc := &mock.FakeGroupReader{} - groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { - return domain.Group{ - ID: groupID, - ServerID: serverID, - ChannelGains: "", - ChannelLosses: "", - ServerKey: "pl151", - VersionCode: "pl", - CreatedAt: time.Now(), - }, nil - }) - - client := &mock.FakeTWHelpClient{} - tribe := twhelp.Tribe{ - ID: 150, - Tag: "*TAG*", - Name: uuid.NewString(), - ProfileURL: "https://pl151.plemiona.pl/game.php?screen=info_player&id=150", - DeletedAt: time.Time{}, - } - client.ListTribesReturns([]twhelp.Tribe{tribe}, nil) - - groupID := uuid.NewString() - - monitor, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10). - Create(context.Background(), groupID, uuid.NewString(), tribe.Tag) - assert.NoError(t, err) - assert.NotEmpty(t, monitor.ID) - assert.Equal(t, groupID, monitor.GroupID) - assert.Equal(t, tribe.ID, monitor.TribeID) - assert.NotEmpty(t, monitor.CreatedAt) - }) - - t.Run("ERR: group doesn't exist", func(t *testing.T) { - t.Parallel() - - groupID := uuid.NewString() - groupSvc := &mock.FakeGroupReader{} - groupSvc.GetReturns(domain.Group{}, domain.GroupNotFoundError{ID: groupID}) - - monitor, err := service.NewMonitor(nil, groupSvc, nil, zap.NewNop(), 10). - Create(context.Background(), groupID, uuid.NewString(), "tag") - assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ - ID: groupID, - }) - assert.Zero(t, monitor) - }) - - t.Run("ERR: monitor limit has been reached", func(t *testing.T) { - t.Parallel() - - const maxMonitorsPerGroup = 10 - - repo := &mock.FakeMonitorRepository{} - repo.ListReturns(make([]domain.Monitor, maxMonitorsPerGroup), nil) - - groupSvc := &mock.FakeGroupReader{} - groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { - return domain.Group{ - ID: groupID, - ServerID: serverID, - ChannelGains: "", - ChannelLosses: "", - ServerKey: "pl151", - VersionCode: "pl", - CreatedAt: time.Now(), - }, nil - }) - - monitor, err := service.NewMonitor(repo, groupSvc, nil, zap.NewNop(), maxMonitorsPerGroup). - Create(context.Background(), uuid.NewString(), uuid.NewString(), "TAG") - assert.ErrorIs(t, err, domain.MonitorLimitReachedError{ - Current: maxMonitorsPerGroup, - Limit: maxMonitorsPerGroup, - }) - assert.Zero(t, monitor) - }) - - t.Run("ERR: tribe doesn't exist", func(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - err error - }{ - { - name: "err=null", - }, - { - name: "err!=null", - err: twhelp.APIError{ - Code: twhelp.ErrorCodeInternalServerError, - Message: "internal server error", - }, - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - repo := &mock.FakeMonitorRepository{} - repo.ListReturns(nil, nil) - repo.CreateCalls(func(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) { - return domain.Monitor{ - ID: uuid.NewString(), - TribeID: params.TribeID(), - GroupID: params.GroupID(), - CreatedAt: time.Now(), - }, nil - }) - - groupSvc := &mock.FakeGroupReader{} - groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { - return domain.Group{ - ID: groupID, - ServerID: serverID, - ChannelGains: "", - ChannelLosses: "", - ServerKey: "pl151", - VersionCode: "pl", - CreatedAt: time.Now(), - }, nil - }) - - client := &mock.FakeTWHelpClient{} - client.ListTribesReturns(nil, tt.err) - - tag := "TAG" - - monitor, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10). - Create(context.Background(), uuid.NewString(), uuid.NewString(), tag) - assert.ErrorIs(t, err, domain.TribeDoesNotExistError{ - Tag: tag, - }) - assert.Zero(t, monitor) - }) - } - }) -} - -func TestMonitor_List(t *testing.T) { - t.Parallel() - - groupID := uuid.NewString() - groupSvc := &mock.FakeGroupReader{} - groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { - return domain.Group{ - ID: groupID, - ServerID: serverID, - ChannelGains: "", - ChannelLosses: "", - ServerKey: "pl151", - VersionCode: "pl", - CreatedAt: time.Now(), - }, nil - }) - - repo := &mock.FakeMonitorRepository{} - monitors := []domain.Monitor{ - { - ID: uuid.NewString(), - TribeID: 124, - GroupID: groupID, - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - TribeID: 125, - GroupID: groupID, - CreatedAt: time.Now(), - }, - } - repo.ListReturns(monitors, nil) - - client := &mock.FakeTWHelpClient{} - client.GetTribeByIDCalls(func(ctx context.Context, _ string, _ string, id int64) (twhelp.Tribe, error) { - return twhelp.Tribe{ - ID: id, - Tag: uuid.NewString(), - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - DeletedAt: time.Time{}, - }, nil - }) - - res, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10). - List(context.Background(), groupID, uuid.NewString()) - assert.NoError(t, err) - assert.Len(t, res, len(monitors)) - for i, m := range monitors { - assert.Equal(t, m, res[i].Monitor) - assert.Equal(t, m.TribeID, res[i].Tribe.ID) - } -} - -func TestMonitor_Execute(t *testing.T) { - t.Parallel() - - client := &mock.FakeTWHelpClient{} - tribes := map[string][]twhelp.TribeMeta{ - "pl:pl181": { - { - ID: 1, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 2, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 3, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - }, - "en:en130": { - { - ID: 100, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 101, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - }, - "pl:pl180": { - { - ID: 200, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - }, - "de:de200": { - { - ID: 300, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 301, - Name: uuid.NewString(), - Tag: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - }, - } - players := map[string][]twhelp.PlayerMeta{ - "pl:pl181": { - { - ID: 1, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["pl:pl181"][0], - Valid: true, - }, - }, - { - ID: 2, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["pl:pl181"][1], - Valid: true, - }, - }, - { - ID: 3, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["pl:pl181"][2], - Valid: true, - }, - }, - { - ID: 4, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{}, - }, - }, - "en:en130": { - { - ID: 100, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["en:en130"][0], - Valid: true, - }, - }, - { - ID: 101, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["en:en130"][1], - Valid: true, - }, - }, - { - ID: 102, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{}, - }, - }, - "pl:pl180": { - { - ID: 200, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["pl:pl180"][0], - Valid: true, - }, - }, - }, - "de:de200": { - { - ID: 300, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["de:de200"][0], - Valid: true, - }, - }, - { - ID: 301, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["de:de200"][0], - Valid: true, - }, - }, - { - ID: 302, - Name: uuid.NewString(), - ProfileURL: uuid.NewString(), - Tribe: twhelp.NullTribeMeta{ - Tribe: tribes["de:de200"][1], - Valid: true, - }, - }, - }, - } - villages := map[string][]twhelp.VillageMeta{ - "pl:pl181": { - { - ID: 1, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][1], - Valid: true, - }, - }, - { - ID: 2, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][0], - Valid: true, - }, - }, - { - ID: 3, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][2], - Valid: true, - }, - }, - { - ID: 4, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 5, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][0], - Valid: true, - }, - }, - }, - "en:en130": { - { - ID: 100, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 101, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["en:en130"][0], - Valid: true, - }, - }, - { - ID: 102, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["en:en130"][2], - Valid: true, - }, - }, - }, - "pl:pl180": { - { - ID: 200, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - }, - "de:de200": { - { - ID: 300, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - }, - { - ID: 301, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["de:de200"][1], - Valid: true, - }, - }, - { - ID: 302, - FullName: uuid.NewString(), - ProfileURL: uuid.NewString(), - Player: twhelp.NullPlayerMeta{ - Player: players["de:de200"][2], - Valid: true, - }, - }, - }, - } - ennoblements := map[string][]twhelp.Ennoblement{ - "pl:pl181": { - { - ID: 1, - Village: villages["pl:pl181"][0], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 2, // self conquer, should be skipped - Village: villages["pl:pl181"][1], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-4 * time.Minute), - }, - { - ID: 3, // internal, should be skipped (internals disabled) - Village: villages["pl:pl181"][2], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-3 * time.Minute), - }, - { - ID: 4, // barbarian, shouldn't be skipped (barbarians enabled) - Village: villages["pl:pl181"][3], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 5, // disabled notifications about gains, should be skipped - Village: villages["pl:pl181"][4], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][1], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 6, // disabled notifications about losses, should be skipped - Village: villages["pl:pl181"][4], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl181"][3], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - }, - "en:en130": { - { - ID: 100, // no monitor for these tribes, should be skipped - Village: villages["en:en130"][0], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["en:en130"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 101, // no monitor for these tribes, should be skipped - Village: villages["en:en130"][1], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["en:en130"][2], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 102, - Village: villages["en:en130"][2], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["en:en130"][1], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - }, - "pl:pl180": { - { - ID: 200, // api error, should be skipped - Village: villages["pl:pl180"][0], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["pl:pl180"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - }, - "de:de200": { - { - ID: 300, // barbarian, should be skipped (barbarians disabled) - Village: villages["de:de200"][0], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["de:de200"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 301, // internal, shouldn't be skipped (internals enabled) - Village: villages["de:de200"][1], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["de:de200"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - { - ID: 302, // internal, shouldn't be skipped (internals enabled) - Village: villages["de:de200"][2], - NewOwner: twhelp.NullPlayerMeta{ - Player: players["de:de200"][0], - Valid: true, - }, - CreatedAt: time.Now().Add(-5 * time.Minute), - }, - }, - } - client.ListEnnoblementsCalls( - func( - ctx context.Context, - version string, - server string, - _ twhelp.ListEnnoblementsQueryParams, - ) ([]twhelp.Ennoblement, error) { - if version == "pl" && server == "pl180" { - return nil, errors.New("random error") - } - return ennoblements[version+":"+server], nil - }, - ) - - groupSvc := &mock.FakeGroupReader{} - groups := []domain.Group{ - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: "", - Barbarians: true, - ServerKey: "pl181", - VersionCode: "pl", - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: "", - ChannelLosses: uuid.NewString(), - ServerKey: "pl181", - VersionCode: "pl", - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: uuid.NewString(), - ServerKey: "en130", - VersionCode: "en", - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: uuid.NewString(), - Barbarians: true, - ServerKey: "pl180", - VersionCode: "pl", - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - ServerID: uuid.NewString(), - ChannelGains: uuid.NewString(), - ChannelLosses: uuid.NewString(), - Barbarians: false, - Internals: true, - ServerKey: "de200", - VersionCode: "de", - CreatedAt: time.Now(), - }, - } - groupSvc.ListReturns(groups, nil) - - repo := &mock.FakeMonitorRepository{} - monitors := map[string][]domain.Monitor{ - groups[0].ID: { - { - ID: uuid.NewString(), - TribeID: tribes["pl:pl181"][0].ID, - GroupID: groups[0].ID, - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - TribeID: tribes["pl:pl181"][2].ID, - GroupID: groups[0].ID, - CreatedAt: time.Now(), - }, - }, - groups[1].ID: { - { - ID: uuid.NewString(), - TribeID: tribes["pl:pl181"][1].ID, - GroupID: groups[1].ID, - CreatedAt: time.Now(), - }, - }, - groups[2].ID: { - { - ID: uuid.NewString(), - TribeID: tribes["en:en130"][1].ID, - GroupID: groups[2].ID, - CreatedAt: time.Now(), - }, - }, - groups[3].ID: { - { - ID: uuid.NewString(), - TribeID: tribes["pl:pl180"][0].ID, - GroupID: groups[3].ID, - CreatedAt: time.Now(), - }, - }, - groups[4].ID: { - { - ID: uuid.NewString(), - TribeID: tribes["de:de200"][0].ID, - GroupID: groups[4].ID, - CreatedAt: time.Now(), - }, - { - ID: uuid.NewString(), - TribeID: tribes["de:de200"][1].ID, - GroupID: groups[4].ID, - CreatedAt: time.Now(), - }, - }, - } - repo.ListCalls(func(ctx context.Context, groupID string) ([]domain.Monitor, error) { - return monitors[groupID], nil - }) - - notifications, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10). - Execute(context.Background()) - assert.NoError(t, err) - expectedNotifications := []domain.EnnoblementNotification{ - newNotification(domain.EnnoblementNotificationTypeGain, groups[0].ServerID, groups[0].ChannelGains, ennoblements["pl:pl181"][0]), - newNotification(domain.EnnoblementNotificationTypeGain, groups[0].ServerID, groups[0].ChannelGains, ennoblements["pl:pl181"][3]), - newNotification(domain.EnnoblementNotificationTypeLoss, groups[1].ServerID, groups[1].ChannelLosses, ennoblements["pl:pl181"][0]), - newNotification(domain.EnnoblementNotificationTypeGain, groups[2].ServerID, groups[2].ChannelGains, ennoblements["en:en130"][2]), - newNotification(domain.EnnoblementNotificationTypeGain, groups[4].ServerID, groups[4].ChannelGains, ennoblements["de:de200"][1]), - newNotification(domain.EnnoblementNotificationTypeGain, groups[4].ServerID, groups[4].ChannelGains, ennoblements["de:de200"][2]), - } - assert.Len(t, notifications, len(expectedNotifications)) - for _, n := range expectedNotifications { - assert.Contains(t, notifications, n) - } -} - -func newNotification(typ domain.EnnoblementNotificationType, serverID, channelID string, e twhelp.Ennoblement) domain.EnnoblementNotification { - return domain.EnnoblementNotification{ - Type: typ, - ServerID: serverID, - ChannelID: channelID, - Ennoblement: domain.Ennoblement{ - ID: e.ID, - Village: domain.VillageMeta{ - ID: e.Village.ID, - FullName: e.Village.FullName, - ProfileURL: e.Village.ProfileURL, - Player: domain.NullPlayerMeta{ - Player: domain.PlayerMeta{ - ID: e.Village.Player.Player.ID, - Name: e.Village.Player.Player.Name, - ProfileURL: e.Village.Player.Player.ProfileURL, - Tribe: domain.NullTribeMeta{ - Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe), - Valid: e.Village.Player.Player.Tribe.Valid, - }, - }, - Valid: e.Village.Player.Valid, - }, - }, - NewOwner: domain.NullPlayerMeta{ - Player: domain.PlayerMeta{ - ID: e.NewOwner.Player.ID, - Name: e.NewOwner.Player.Name, - ProfileURL: e.NewOwner.Player.ProfileURL, - Tribe: domain.NullTribeMeta{ - Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe), - Valid: e.NewOwner.Player.Tribe.Valid, - }, - }, - Valid: e.NewOwner.Valid, - }, - CreatedAt: e.CreatedAt, - }, - } -} diff --git a/internal/twhelp/client.go b/internal/twhelp/client.go index 6a5dcdb..c7a4271 100644 --- a/internal/twhelp/client.go +++ b/internal/twhelp/client.go @@ -9,6 +9,8 @@ import ( "net/url" "strconv" "time" + + "golang.org/x/time/rate" ) const ( @@ -24,9 +26,10 @@ const ( ) type Client struct { - userAgent string - client *http.Client - baseURL *url.URL + userAgent string + client *http.Client + baseURL *url.URL + rateLimiter *rate.Limiter } type ClientOption func(c *Client) @@ -43,6 +46,12 @@ func WithUserAgent(ua string) ClientOption { } } +func WithRateLimiter(rl *rate.Limiter) ClientOption { + return func(c *Client) { + c.rateLimiter = rl + } +} + func NewClient(baseURL *url.URL, opts ...ClientOption) *Client { c := &Client{ baseURL: baseURL, @@ -216,6 +225,13 @@ func (c *Client) getJSON(ctx context.Context, urlStr string, v any) error { // headers req.Header.Set("User-Agent", c.userAgent) + // rate limiter + if c.rateLimiter != nil { + if err = c.rateLimiter.Wait(ctx); err != nil { + return err + } + } + resp, err := c.client.Do(req) if err != nil { return fmt.Errorf("client.Do: %w", err) diff --git a/k8s/base/bot.yml b/k8s/base/bot.yml index f6bfce7..3013dca 100644 --- a/k8s/base/bot.yml +++ b/k8s/base/bot.yml @@ -34,8 +34,10 @@ spec: key: token - name: TWHELP_URL value: "https://tribalwarshelp.com" + - name: TWHELP_RATE_LIMITER_ENABLED + value: "false" - name: BOT_MAX_GROUPS_PER_SERVER - value: "10" + value: "5" - name: BOT_MAX_MONITORS_PER_GROUP value: "10" livenessProbe: @@ -49,4 +51,4 @@ spec: memory: 100Mi limits: cpu: 250m - memory: 300Mi \ No newline at end of file + memory: 300Mi diff --git a/k8s/overlays/dev/bot.yml b/k8s/overlays/dev/bot.yml new file mode 100644 index 0000000..41017ad --- /dev/null +++ b/k8s/overlays/dev/bot.yml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: twhelp-dcbot-deployment +spec: + template: + spec: + containers: + - name: twhelp-dcbot + image: dcbot + env: + - name: APP_MODE + value: development + - name: DB_DSN + valueFrom: + secretKeyRef: + name: twhelp-dcbot-secret + key: db-dsn + - name: DB_MAX_OPEN_CONNECTIONS + value: "10" + - name: DB_MAX_IDLE_CONNECTIONS + value: "3" + - name: BOT_TOKEN + valueFrom: + secretKeyRef: + name: twhelp-dcbot-secret + key: token + - name: TWHELP_URL + value: "https://tribalwarshelp.com" + - name: TWHELP_RATE_LIMITER_ENABLED + value: "true" + - name: TWHELP_RATE_LIMITER_MAX_REQUESTS_PER_SECOND + value: "10" + - name: TWHELP_RATE_LIMITER_REQUEST_BURST + value: "5" + - name: BOT_MAX_GROUPS_PER_SERVER + value: "5" + - name: BOT_MAX_MONITORS_PER_GROUP + value: "10" diff --git a/k8s/overlays/dev/kustomization.yml b/k8s/overlays/dev/kustomization.yml index ade095e..498c03e 100644 --- a/k8s/overlays/dev/kustomization.yml +++ b/k8s/overlays/dev/kustomization.yml @@ -3,4 +3,6 @@ kind: Kustomization nameSuffix: -dev resources: - secret.yml - - ../../base \ No newline at end of file + - ../../base +patchesStrategicMerge: + - bot.yml