diff --git a/cmd/dcbot/internal/run/run.go b/cmd/dcbot/internal/run/run.go index 7ecc25f..e039d20 100644 --- a/cmd/dcbot/internal/run/run.go +++ b/cmd/dcbot/internal/run/run.go @@ -28,6 +28,11 @@ func New() *cli.Command { Action: func(c *cli.Context) error { logger := zap.L() + cfg, err := newBotConfig() + if err != nil { + return fmt.Errorf("newBotConfig: %w", err) + } + db, err := internal.NewBunDB() if err != nil { return fmt.Errorf("internal.NewBunDB: %w", err) @@ -43,22 +48,18 @@ func New() *cli.Command { choiceSvc := service.NewChoice(client) groupSvc := service.NewGroup(groupRepo, client) - monitorSvc := service.NewMonitor(monitorRepo, groupRepo, client) - - cfg, err := newBotConfig() - if err != nil { - return fmt.Errorf("newBotConfig: %w", err) - } + monitorSvc := service.NewMonitor(monitorRepo, groupRepo, client, cfg.EnnoblementsReqLimit) bot, err := discord.NewBot(cfg.Token, groupSvc, monitorSvc, choiceSvc) if err != nil { return fmt.Errorf("discord.NewBot: %w", err) } - defer func() { - _ = bot.Close() + + go func() { + _ = bot.Run() }() - if err := os.WriteFile(healthyFilePath, []byte("healthy"), healthyFilePerm); err != nil { + if err = os.WriteFile(healthyFilePath, []byte("healthy"), healthyFilePerm); err != nil { return fmt.Errorf("couldn't create file (path=%s): %w", healthyFilePath, err) } @@ -66,13 +67,18 @@ func New() *cli.Command { waitForSignal(c.Context) + if err = bot.Close(); err != nil { + return fmt.Errorf("bot.Close: %w", err) + } + return nil }, } } type botConfig struct { - Token string `envconfig:"TOKEN" required:"true"` + Token string `envconfig:"TOKEN" required:"true"` + EnnoblementsReqLimit int `envconfig:"ENNOBLEMENTS_REQ_LIMIT" default:"4"` } func newBotConfig() (botConfig, error) { diff --git a/go.mod b/go.mod index fd1f68c..2730244 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/opencontainers/runc v1.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.8.1 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect diff --git a/go.sum b/go.sum index 3652396..af9e1bb 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/discord/bot.go b/internal/discord/bot.go index c17a13c..06a5b0a 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -3,9 +3,11 @@ package discord import ( "context" "fmt" + "time" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "github.com/bwmarrin/discordgo" + "github.com/robfig/cron/v3" ) type GroupService interface { @@ -18,6 +20,7 @@ type GroupService interface { type MonitorService interface { Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) + Execute(ctx context.Context) error Delete(ctx context.Context, id, serverID string) error } @@ -27,6 +30,7 @@ type ChoiceService interface { type Bot struct { s *discordgo.Session + c *cron.Cron groupSvc GroupService monitorSvc MonitorService choiceSvc ChoiceService @@ -38,19 +42,46 @@ func NewBot(token string, groupSvc GroupService, monitorSvc MonitorService, clie return nil, fmt.Errorf("discordgo.New: %w", err) } - if err = s.Open(); err != nil { - return nil, fmt.Errorf("s.Open: %w", err) - } - - b := &Bot{s: s, groupSvc: groupSvc, monitorSvc: monitorSvc, choiceSvc: client} - if err = b.registerCommands(); err != nil { - _ = s.Close() - return nil, fmt.Errorf("couldn't register commands: %w", err) + b := &Bot{ + s: s, + c: cron.New( + cron.WithLocation(time.UTC), + cron.WithChain( + cron.SkipIfStillRunning(cron.DiscardLogger), + ), + ), + groupSvc: groupSvc, + monitorSvc: monitorSvc, + choiceSvc: client, } return b, nil } +func (b *Bot) Run() error { + if err := b.initCron(); err != nil { + return fmt.Errorf("initCron: %w", err) + } + + if err := b.s.Open(); err != nil { + return fmt.Errorf("s.Open: %w", err) + } + + if err := b.registerCommands(); err != nil { + _ = b.s.Close() + return fmt.Errorf("couldn't register commands: %w", err) + } + + b.c.Run() + + return nil +} + +type command interface { + name() string + register(s *discordgo.Session) error +} + func (b *Bot) registerCommands() error { commands := []command{ &groupCommand{groupSvc: b.groupSvc, choiceSvc: b.choiceSvc}, @@ -66,11 +97,6 @@ func (b *Bot) registerCommands() error { return nil } -type command interface { - name() string - register(s *discordgo.Session) error -} - func (b *Bot) registerCommand(cmd command) error { if err := cmd.register(b.s); err != nil { return fmt.Errorf("couldn't register command '%s': %w", cmd.name(), err) @@ -78,6 +104,15 @@ func (b *Bot) registerCommand(cmd command) error { return nil } +func (b *Bot) initCron() error { + _, err := b.c.AddJob("@every 1m", &executeMonitorsJob{svc: b.monitorSvc}) + if err != nil { + return err + } + return nil +} + func (b *Bot) Close() error { + <-b.c.Stop().Done() return b.s.Close() } diff --git a/internal/discord/job_execute_monitors.go b/internal/discord/job_execute_monitors.go new file mode 100644 index 0000000..39f19f8 --- /dev/null +++ b/internal/discord/job_execute_monitors.go @@ -0,0 +1,22 @@ +package discord + +import ( + "context" + "fmt" + "time" +) + +const ( + executeMonitorsJobTimeout = 2 * time.Minute +) + +type executeMonitorsJob struct { + svc MonitorService +} + +func (e *executeMonitorsJob) Run() { + ctx, cancel := context.WithTimeout(context.Background(), executeMonitorsJobTimeout) + defer cancel() + fmt.Println("execute") + _ = e.svc.Execute(ctx) +} diff --git a/internal/service/monitor.go b/internal/service/monitor.go index d94c717..79f7790 100644 --- a/internal/service/monitor.go +++ b/internal/service/monitor.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "sync" + "time" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" @@ -21,19 +23,33 @@ type MonitorRepository interface { Delete(ctx context.Context, id string) error } -//counterfeiter:generate -o internal/mock/group_getter.gen.go . GroupGetter -type GroupGetter interface { +//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 GroupGetter + repo MonitorRepository + client TWHelpClient + groupSvc GroupReader + lastExecutionMu sync.RWMutex + lastExecution time.Time + ennoblementsReqLimit int } -func NewMonitor(repo MonitorRepository, groupSvc GroupGetter, client TWHelpClient) *Monitor { - return &Monitor{repo: repo, client: client, groupSvc: groupSvc} +func NewMonitor( + repo MonitorRepository, + groupSvc GroupReader, + client TWHelpClient, + ennoblementsReqLimit int, +) *Monitor { + return &Monitor{ + repo: repo, + client: client, + groupSvc: groupSvc, + ennoblementsReqLimit: ennoblementsReqLimit, + } } func (m *Monitor) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) { @@ -107,3 +123,110 @@ func (m *Monitor) Delete(ctx context.Context, id, serverID string) error { return nil } + +func (m *Monitor) Execute(ctx context.Context) error { + groups, err := m.groupSvc.List(ctx, domain.ListGroupsParams{}) + if err != nil { + return fmt.Errorf("GroupService.List: %w", err) + } + + ennoblements, err := m.preloadEnnoblements(ctx, groups) + if err != nil { + return err + } + + for _, e := range ennoblements { + fmt.Println(e) + } + + return nil +} + +type listEnnoblementsResult struct { + versionCode string + serverKey string + ennoblements []twhelp.Ennoblement + err error +} + +type preloadEnnoblementsResult struct { + VersionCode string + ServerKey string + Ennoblements []twhelp.Ennoblement +} + +func (m *Monitor) preloadEnnoblements(ctx context.Context, groups []domain.Group) ([]preloadEnnoblementsResult, error) { + var wg sync.WaitGroup + ch := make(chan listEnnoblementsResult) + reqLimitCh := make(chan struct{}, m.ennoblementsReqLimit) + skip := make(map[string]struct{}) + + m.lastExecutionMu.RLock() + since := m.lastExecution + m.lastExecutionMu.RUnlock() + if since.IsZero() { + since = time.Now().Add(-1 * time.Minute) + } + since = since.Add(1 * time.Millisecond) + + for _, g := range groups { + if g.ChannelGains == "" && g.ChannelLosses == "" { + continue + } + + key := g.VersionCode + ":" + g.ServerKey + if _, ok := skip[key]; ok { + continue + } + skip[key] = struct{}{} + + wg.Add(1) + go func(g domain.Group, since time.Time) { + reqLimitCh <- struct{}{} + defer func() { + <-reqLimitCh + }() + + res := listEnnoblementsResult{ + versionCode: g.VersionCode, + serverKey: g.ServerKey, + } + res.ennoblements, res.err = m.client.ListEnnoblements( + ctx, + g.VersionCode, + g.ServerKey, + twhelp.ListEnnoblementsQueryParams{ + Since: since, + }, + ) + ch <- res + }(g, since) + } + + go func() { + wg.Wait() + close(ch) + }() + + //nolint:prealloc + var results []preloadEnnoblementsResult + for res := range ch { + wg.Done() + + if res.err != nil { + continue + } + + results = append(results, preloadEnnoblementsResult{ + VersionCode: res.versionCode, + ServerKey: res.serverKey, + Ennoblements: res.ennoblements, + }) + } + + m.lastExecutionMu.Lock() + m.lastExecution = time.Now() + m.lastExecutionMu.Unlock() + + return results, nil +} diff --git a/internal/service/monitor_test.go b/internal/service/monitor_test.go index 7a18247..e70f443 100644 --- a/internal/service/monitor_test.go +++ b/internal/service/monitor_test.go @@ -30,7 +30,7 @@ func TestMonitor_Create(t *testing.T) { }, nil }) - groupSvc := &mock.FakeGroupGetter{} + groupSvc := &mock.FakeGroupReader{} groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { return domain.Group{ ID: groupID, @@ -55,7 +55,7 @@ func TestMonitor_Create(t *testing.T) { groupID := uuid.NewString() - monitor, err := service.NewMonitor(repo, groupSvc, client). + monitor, err := service.NewMonitor(repo, groupSvc, client, 10). Create(context.Background(), groupID, uuid.NewString(), tribe.Tag) assert.NoError(t, err) assert.NotEmpty(t, monitor.ID) @@ -68,10 +68,10 @@ func TestMonitor_Create(t *testing.T) { t.Parallel() groupID := uuid.NewString() - groupSvc := &mock.FakeGroupGetter{} + groupSvc := &mock.FakeGroupReader{} groupSvc.GetReturns(domain.Group{}, domain.GroupNotFoundError{ID: groupID}) - monitor, err := service.NewMonitor(nil, groupSvc, nil). + monitor, err := service.NewMonitor(nil, groupSvc, nil, 10). Create(context.Background(), groupID, uuid.NewString(), "tag") assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ ID: groupID, @@ -87,7 +87,7 @@ func TestMonitor_Create(t *testing.T) { repo := &mock.FakeMonitorRepository{} repo.ListReturns(make([]domain.Monitor, maxMonitorsPerGroup), nil) - groupSvc := &mock.FakeGroupGetter{} + groupSvc := &mock.FakeGroupReader{} groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { return domain.Group{ ID: groupID, @@ -100,7 +100,7 @@ func TestMonitor_Create(t *testing.T) { }, nil }) - monitor, err := service.NewMonitor(repo, groupSvc, nil). + monitor, err := service.NewMonitor(repo, groupSvc, nil, 10). Create(context.Background(), uuid.NewString(), uuid.NewString(), "TAG") assert.ErrorIs(t, err, domain.MonitorLimitReachedError{ Current: maxMonitorsPerGroup, @@ -126,7 +126,7 @@ func TestMonitor_Create(t *testing.T) { }, nil }) - groupSvc := &mock.FakeGroupGetter{} + groupSvc := &mock.FakeGroupReader{} groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { return domain.Group{ ID: groupID, @@ -147,7 +147,7 @@ func TestMonitor_Create(t *testing.T) { tag := "TAG" - monitor, err := service.NewMonitor(repo, groupSvc, client). + monitor, err := service.NewMonitor(repo, groupSvc, client, 10). Create(context.Background(), uuid.NewString(), uuid.NewString(), tag) assert.ErrorIs(t, err, domain.TribeDoesNotExistError{ Tag: tag, @@ -161,7 +161,7 @@ func TestMonitor_Create(t *testing.T) { repo := &mock.FakeMonitorRepository{} repo.ListReturns(nil, nil) - groupSvc := &mock.FakeGroupGetter{} + groupSvc := &mock.FakeGroupReader{} groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { return domain.Group{ ID: groupID, @@ -184,7 +184,7 @@ func TestMonitor_Create(t *testing.T) { } client.GetTribeByTagReturns(tribe, nil) - monitor, err := service.NewMonitor(repo, groupSvc, client). + monitor, err := service.NewMonitor(repo, groupSvc, client, 10). Create(context.Background(), uuid.NewString(), uuid.NewString(), tribe.Tag) assert.ErrorIs(t, err, domain.TribeDoesNotExistError{ Tag: tribe.Tag, diff --git a/internal/service/service.go b/internal/service/service.go index 710c043..be95e63 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,4 +13,9 @@ type TWHelpClient interface { ListVersions(ctx context.Context) ([]twhelp.Version, error) GetServer(ctx context.Context, version, server string) (twhelp.Server, error) GetTribeByTag(ctx context.Context, version, server, tag string) (twhelp.Tribe, error) + ListEnnoblements( + ctx context.Context, + version, server string, + queryParams twhelp.ListEnnoblementsQueryParams, + ) ([]twhelp.Ennoblement, error) } diff --git a/internal/twhelp/client.go b/internal/twhelp/client.go index 04b5b43..684d98f 100644 --- a/internal/twhelp/client.go +++ b/internal/twhelp/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strconv" "time" ) @@ -14,9 +15,10 @@ const ( defaultUserAgent = "TWHelpDCBot/development" defaultTimeout = 10 * time.Second - endpointListVersions = "/api/v1/versions" - endpointGetServer = "/api/v1/versions/%s/servers/%s" - endpointGetTribeByTag = "/api/v1/versions/%s/servers/%s/tribes/tag/%s" + endpointListVersions = "/api/v1/versions" + endpointGetServer = "/api/v1/versions/%s/servers/%s" + endpointGetTribeByTag = "/api/v1/versions/%s/servers/%s/tribes/tag/%s" + endpointListEnnoblements = "/api/v1/versions/%s/servers/%s/ennoblements" ) type Client struct { @@ -77,6 +79,40 @@ func (c *Client) GetTribeByTag(ctx context.Context, version, server, tag string) return resp.Data, nil } +type ListEnnoblementsQueryParams struct { + Limit int32 + Offset int32 + Since time.Time +} + +func (c *Client) ListEnnoblements( + ctx context.Context, + version, server string, + queryParams ListEnnoblementsQueryParams, +) ([]Ennoblement, error) { + q := url.Values{} + + if queryParams.Limit >= 0 { + q.Set("limit", strconv.Itoa(int(queryParams.Limit))) + } + + if queryParams.Offset >= 0 { + q.Set("offset", strconv.Itoa(int(queryParams.Offset))) + } + + if !queryParams.Since.IsZero() { + q.Set("since", queryParams.Since.Format(time.RFC3339)) + } + + var resp listEnnoblementsResp + + if err := c.getJSON(ctx, fmt.Sprintf(endpointListEnnoblements, version, server)+"?"+q.Encode(), &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + func (c *Client) getJSON(ctx context.Context, urlStr string, v any) error { u, err := c.baseURL.Parse(urlStr) if err != nil { diff --git a/internal/twhelp/twhelp.go b/internal/twhelp/twhelp.go new file mode 100644 index 0000000..fcf149f --- /dev/null +++ b/internal/twhelp/twhelp.go @@ -0,0 +1,154 @@ +package twhelp + +import ( + "encoding/json" + "time" +) + +type ErrorCode string + +const ( + ErrorCodeInternalServerError ErrorCode = "internal-server-error" + ErrorCodeEntityNotFound ErrorCode = "entity-not-found" + ErrorCodeValidationError ErrorCode = "validation-error" + ErrorCodeRouteNotFound ErrorCode = "route-not-found" + ErrorCodeMethodNotAllowed ErrorCode = "method-not-allowed" +) + +func (c ErrorCode) String() string { + return string(c) +} + +type APIError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` +} + +func (e APIError) Error() string { + return e.Message + " (code=" + e.Code.String() + ")" +} + +type errorResp struct { + Error APIError `json:"error"` +} + +type Version struct { + Code string `json:"code"` + Host string `json:"host"` + Name string `json:"name"` + Timezone string `json:"timezone"` +} + +type listVersionsResp struct { + Data []Version `json:"data"` +} + +type Server struct { + Key string `json:"key"` + URL string `json:"url"` + Open bool `json:"open"` +} + +type getServerResp struct { + Data Server `json:"data"` +} + +type Tribe struct { + ID int64 `json:"id"` + Tag string `json:"tag"` + Name string `json:"name"` + ProfileURL string `json:"profileUrl"` + DeletedAt time.Time `json:"deletedAt"` +} + +type getTribeResp struct { + Data Tribe `json:"data"` +} + +type TribeMeta struct { + ID int64 `json:"id"` + Name string `json:"name"` + Tag string `json:"tag"` + ProfileURL string `json:"profileUrl"` +} + +type NullTribeMeta struct { + Tribe TribeMeta + Valid bool +} + +func (t NullTribeMeta) MarshalJSON() ([]byte, error) { + if !t.Valid { + return []byte("null"), nil + } + + return json.Marshal(t.Tribe) +} + +func (t *NullTribeMeta) UnmarshalJSON(data []byte) error { + // Ignore null, like in the main JSON package. + if string(data) == "null" { + return nil + } + + if err := json.Unmarshal(data, &t.Tribe); err != nil { + return err + } + + t.Valid = true + + return nil +} + +type PlayerMeta struct { + ID int64 `json:"id"` + Name string `json:"name"` + ProfileURL string `json:"profileUrl"` + Tribe NullTribeMeta `json:"tribe"` +} + +type NullPlayerMeta struct { + Player PlayerMeta + Valid bool +} + +func (p NullPlayerMeta) MarshalJSON() ([]byte, error) { + if !p.Valid { + return []byte("null"), nil + } + + return json.Marshal(p.Player) +} + +func (p *NullPlayerMeta) UnmarshalJSON(data []byte) error { + // Ignore null, like in the main JSON package. + if string(data) == "null" { + return nil + } + + if err := json.Unmarshal(data, &p.Player); err != nil { + return err + } + + p.Valid = true + + return nil +} + +type VillageMeta struct { + ID int64 `json:"id"` + FullName string `json:"fullName"` + ProfileURL string `json:"profileUrl"` +} + +type Ennoblement struct { + ID int64 `json:"id"` + Village VillageMeta `json:"village"` + NewOwner NullPlayerMeta `json:"newOwner"` + OldOwner NullPlayerMeta `json:"oldOwner"` + CreatedAt time.Time `json:"createdAt"` +} + +type listEnnoblementsResp struct { + Data []Ennoblement `json:"data"` +} diff --git a/internal/twhelp/twhelp_test.go b/internal/twhelp/twhelp_test.go new file mode 100644 index 0000000..567ea32 --- /dev/null +++ b/internal/twhelp/twhelp_test.go @@ -0,0 +1,258 @@ +package twhelp_test + +import ( + "encoding/json" + "testing" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" + "github.com/stretchr/testify/assert" +) + +func TestNullTribeMeta_MarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tribe twhelp.NullTribeMeta + expectedJSON string + }{ + { + name: "OK: null 1", + tribe: twhelp.NullTribeMeta{ + Tribe: twhelp.TribeMeta{}, + Valid: false, + }, + expectedJSON: "null", + }, + { + name: "OK: null 2", + tribe: twhelp.NullTribeMeta{ + Tribe: twhelp.TribeMeta{ID: 1234}, + Valid: false, + }, + expectedJSON: "null", + }, + { + name: "OK: valid struct", + tribe: twhelp.NullTribeMeta{ + Tribe: twhelp.TribeMeta{ + ID: 997, + Name: "name 997", + Tag: "tag 997", + ProfileURL: "profile-997", + }, + Valid: true, + }, + expectedJSON: `{"id":997,"name":"name 997","tag":"tag 997","profileUrl":"profile-997"}`, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b, err := json.Marshal(tt.tribe) + assert.NoError(t, err) + assert.JSONEq(t, tt.expectedJSON, string(b)) + }) + } +} + +func TestNullTribeMeta_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + expectedTribe twhelp.NullTribeMeta + expectedJSONSyntaxError bool + }{ + { + name: "OK: null", + json: "null", + expectedTribe: twhelp.NullTribeMeta{ + Tribe: twhelp.TribeMeta{}, + Valid: false, + }, + }, + { + name: "OK: valid struct", + //nolint:lll + json: `{"id":997,"name":"name 997","tag":"tag 997","profileUrl":"profile-997"}`, + expectedTribe: twhelp.NullTribeMeta{ + Tribe: twhelp.TribeMeta{ + ID: 997, + Name: "name 997", + Tag: "tag 997", + ProfileURL: "profile-997", + }, + Valid: true, + }, + }, + { + name: "ERR: invalid tribe 1", + json: "2022-07-30T14:13:12.0000005Z", + expectedTribe: twhelp.NullTribeMeta{}, + expectedJSONSyntaxError: true, + }, + { + name: "ERR: invalid tribe 2", + json: "hello world", + expectedTribe: twhelp.NullTribeMeta{}, + expectedJSONSyntaxError: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var target twhelp.NullTribeMeta + err := json.Unmarshal([]byte(tt.json), &target) + if tt.expectedJSONSyntaxError { + var syntaxError *json.SyntaxError + assert.ErrorAs(t, err, &syntaxError) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expectedTribe, target) + }) + } +} + +func TestNullPlayerMeta_MarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + player twhelp.NullPlayerMeta + expectedJSON string + }{ + { + name: "OK: null 1", + player: twhelp.NullPlayerMeta{ + Player: twhelp.PlayerMeta{}, + Valid: false, + }, + expectedJSON: "null", + }, + { + name: "OK: null 2", + player: twhelp.NullPlayerMeta{ + Player: twhelp.PlayerMeta{ID: 1234}, + Valid: false, + }, + expectedJSON: "null", + }, + { + name: "OK: valid struct", + player: twhelp.NullPlayerMeta{ + Player: twhelp.PlayerMeta{ + ID: 997, + Name: "name 997", + ProfileURL: "profile-997", + Tribe: twhelp.NullTribeMeta{ + Valid: true, + Tribe: twhelp.TribeMeta{ + ID: 1234, + Name: "name 997", + ProfileURL: "profile-997", + Tag: "tag 997", + }, + }, + }, + Valid: true, + }, + //nolint:lll + expectedJSON: `{"id":997,"name":"name 997","profileUrl":"profile-997","tribe":{"id":1234,"name":"name 997","profileUrl":"profile-997","tag":"tag 997"}}`, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b, err := json.Marshal(tt.player) + assert.NoError(t, err) + assert.JSONEq(t, tt.expectedJSON, string(b)) + }) + } +} + +func TestNullPlayerMeta_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + expectedPlayer twhelp.NullPlayerMeta + expectedJSONSyntaxError bool + }{ + { + name: "OK: null", + json: "null", + expectedPlayer: twhelp.NullPlayerMeta{ + Player: twhelp.PlayerMeta{}, + Valid: false, + }, + }, + { + name: "OK: valid struct", + //nolint:lll + json: `{"id":997,"name":"name 997","profileUrl":"profile-997","tribe":{"id":1234,"name":"name 997","profileUrl":"profile-997","tag":"tag 997"}}`, + expectedPlayer: twhelp.NullPlayerMeta{ + Player: twhelp.PlayerMeta{ + ID: 997, + Name: "name 997", + ProfileURL: "profile-997", + Tribe: twhelp.NullTribeMeta{ + Valid: true, + Tribe: twhelp.TribeMeta{ + ID: 1234, + Name: "name 997", + ProfileURL: "profile-997", + Tag: "tag 997", + }, + }, + }, + Valid: true, + }, + }, + { + name: "ERR: invalid tribe 1", + json: "2022-07-30T14:13:12.0000005Z", + expectedPlayer: twhelp.NullPlayerMeta{}, + expectedJSONSyntaxError: true, + }, + { + name: "ERR: invalid tribe 2", + json: "hello world", + expectedPlayer: twhelp.NullPlayerMeta{}, + expectedJSONSyntaxError: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var target twhelp.NullPlayerMeta + err := json.Unmarshal([]byte(tt.json), &target) + if tt.expectedJSONSyntaxError { + var syntaxError *json.SyntaxError + assert.ErrorAs(t, err, &syntaxError) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expectedPlayer, target) + }) + } +} diff --git a/internal/twhelp/types.go b/internal/twhelp/types.go deleted file mode 100644 index f9b2615..0000000 --- a/internal/twhelp/types.go +++ /dev/null @@ -1,63 +0,0 @@ -package twhelp - -import "time" - -type ErrorCode string - -const ( - ErrorCodeInternalServerError ErrorCode = "internal-server-error" - ErrorCodeEntityNotFound ErrorCode = "entity-not-found" - ErrorCodeValidationError ErrorCode = "validation-error" - ErrorCodeRouteNotFound ErrorCode = "route-not-found" - ErrorCodeMethodNotAllowed ErrorCode = "method-not-allowed" -) - -func (c ErrorCode) String() string { - return string(c) -} - -type APIError struct { - Code ErrorCode `json:"code"` - Message string `json:"message"` -} - -func (e APIError) Error() string { - return e.Message + " (code=" + e.Code.String() + ")" -} - -type errorResp struct { - Error APIError `json:"error"` -} - -type Version struct { - Code string `json:"code"` - Host string `json:"host"` - Name string `json:"name"` - Timezone string `json:"timezone"` -} - -type listVersionsResp struct { - Data []Version `json:"data"` -} - -type Server struct { - Key string `json:"key"` - URL string `json:"url"` - Open bool `json:"open"` -} - -type getServerResp struct { - Data Server `json:"data"` -} - -type Tribe struct { - ID int64 `json:"id"` - Tag string `json:"tag"` - Name string `json:"name"` - ProfileURL string `json:"profileUrl"` - DeletedAt time.Time `json:"deletedAt"` -} - -type getTribeResp struct { - Data Tribe `json:"data"` -} diff --git a/k8s/base/bot.yml b/k8s/base/bot.yml index c6e80a3..c307f8e 100644 --- a/k8s/base/bot.yml +++ b/k8s/base/bot.yml @@ -34,6 +34,8 @@ spec: key: token - name: TWHELP_URL value: "https://tribalwarshelp.com" + - name: BOT_ENNOBLEMENTS_REQ_LIMIT + value: "4" livenessProbe: exec: command: [ "cat", "/tmp/healthy" ]