feat: notifications #25

Merged
Kichiyaki merged 15 commits from feat/notifications into master 2022-10-27 09:56:40 +00:00
18 changed files with 1486 additions and 113 deletions

View File

@ -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.EnnoblementsMaxConcurrentRequests)
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"`
EnnoblementsMaxConcurrentRequests int `envconfig:"ENNOBLEMENTS_MAX_CONCURRENT_REQUESTS" default:"4"`
}
func newBotConfig() (botConfig, error) {

1
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/google/uuid v1.3.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/ory/dockertest/v3 v3.9.1
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.8.0
github.com/uptrace/bun v1.1.8
github.com/uptrace/bun/dbfixture v1.1.8

2
go.sum
View File

@ -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=

View File

@ -182,11 +182,9 @@ func TestMonitor_Get(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.ErrorIs(
t,
repo.Delete(context.Background(), tt.id),
domain.MonitorNotFoundError{ID: tt.id},
)
monitor, err := repo.Get(context.Background(), tt.id)
assert.ErrorIs(t, err, domain.MonitorNotFoundError{ID: tt.id})
assert.Zero(t, monitor)
})
}
})

View File

@ -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) ([]domain.EnnoblementNotification, 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, s: b.s})
if err != nil {
return err
}
return nil
}
func (b *Bot) Close() error {
<-b.c.Stop().Done()
return b.s.Close()
}

View File

@ -317,7 +317,7 @@ func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.Interaction
Type: discordgo.EmbedTypeRich,
Title: "Group list",
Description: buildGroupListDescription(groups),
Timestamp: time.Now().Format(time.RFC3339),
Timestamp: formatTimestamp(time.Now()),
},
},
},

View File

@ -2,10 +2,16 @@ package discord
import (
"errors"
"fmt"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
)
const (
embedDescriptionCharLimit = 4096
)
func messageFromError(err error) string {
var userErr domain.UserError
if !errors.As(err, &userErr) {
@ -13,3 +19,14 @@ func messageFromError(err error) string {
}
return userErr.UserError()
}
func formatTimestamp(t time.Time) string {
return t.Format(time.RFC3339)
}
func buildLink(text string, url string) string {
if url == "" {
return text
}
return fmt.Sprintf("[`%s`](%s)", text, url)
}

View File

@ -0,0 +1,126 @@
package discord
import (
"context"
"fmt"
"sync"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/bwmarrin/discordgo"
)
const (
executeMonitorsJobTimeout = 2 * time.Minute
colorGreen = 0x00ff00
colorRed = 0xff0000
)
type executeMonitorsJob struct {
s *discordgo.Session
svc MonitorService
}
func (e *executeMonitorsJob) Run() {
ctx, cancel := context.WithTimeout(context.Background(), executeMonitorsJobTimeout)
defer cancel()
notifications, err := e.svc.Execute(ctx)
if err != nil {
// TODO: log this error
return
}
var wg sync.WaitGroup
for ch, embeds := range ennoblementNotificationsToEmbeds(notifications) {
wg.Add(1)
go func(ch string, embeds []*discordgo.MessageEmbed) {
defer wg.Done()
for _, embed := range embeds {
_, _ = e.s.ChannelMessageSendEmbed(ch, embed)
}
}(ch, embeds)
}
wg.Wait()
}
func ennoblementNotificationsToEmbeds(ns []domain.EnnoblementNotification) map[string][]*discordgo.MessageEmbed {
m := make(map[string]map[domain.EnnoblementNotificationType][]*discordgo.MessageEmbed)
timestamp := formatTimestamp(time.Now())
for _, n := range ns {
if _, ok := m[n.ChannelID]; !ok {
m[n.ChannelID] = make(map[domain.EnnoblementNotificationType][]*discordgo.MessageEmbed)
}
embeds := m[n.ChannelID][n.Type]
str := buildEnnoblementNotificationDescription(n)
if l := len(embeds); l == 0 || len(embeds[l-1].Description)+len(str) > embedDescriptionCharLimit {
embeds = append(embeds, &discordgo.MessageEmbed{
Color: ennoblementNotificationTypeToColor(n.Type),
Type: discordgo.EmbedTypeRich,
Timestamp: timestamp,
})
}
if len(embeds[len(embeds)-1].Description) > 0 {
str = "\n" + str
}
embeds[len(embeds)-1].Description += str
m[n.ChannelID][n.Type] = embeds
}
res := make(map[string][]*discordgo.MessageEmbed, len(m))
for ch, innerMap := range m {
cnt := 0
for _, embeds := range innerMap {
cnt += len(embeds)
}
res[ch] = make([]*discordgo.MessageEmbed, 0, cnt)
for _, embeds := range innerMap {
res[ch] = append(res[ch], embeds...)
}
}
return res
}
func buildEnnoblementNotificationDescription(n domain.EnnoblementNotification) string {
return fmt.Sprintf(
"%s has taken %s (Old owner: %s)",
nullPlayerMetaToMarkdown(n.Ennoblement.NewOwner),
buildLink(n.Ennoblement.Village.FullName, n.Ennoblement.Village.ProfileURL),
nullPlayerMetaToMarkdown(n.Ennoblement.OldOwner),
)
}
func nullPlayerMetaToMarkdown(p domain.NullPlayerMeta) string {
if !p.Valid {
return "Barbarians"
}
tag := p.Player.Tribe.Tribe.Tag
if tag == "" {
tag = "-"
}
return buildLink(p.Player.Name, p.Player.ProfileURL) +
" [" +
buildLink(tag, p.Player.Tribe.Tribe.ProfileURL) +
"]"
}
func ennoblementNotificationTypeToColor(t domain.EnnoblementNotificationType) int {
switch t {
case domain.EnnoblementNotificationTypeGain:
return colorGreen
case domain.EnnoblementNotificationTypeLoss:
fallthrough
default:
return colorRed
}
}

View File

@ -0,0 +1,15 @@
package domain
type EnnoblementNotificationType uint8
const (
EnnoblementNotificationTypeGain = iota
EnnoblementNotificationTypeLoss
)
type EnnoblementNotification struct {
Type EnnoblementNotificationType
ServerID string
ChannelID string
Ennoblement Ennoblement
}

View File

@ -1,6 +1,47 @@
package domain
import "fmt"
import (
"fmt"
"time"
)
type TribeMeta struct {
ID int64
Name string
Tag string
ProfileURL string
}
type NullTribeMeta struct {
Tribe TribeMeta
Valid bool
}
type PlayerMeta struct {
ID int64
Name string
ProfileURL string
Tribe NullTribeMeta
}
type NullPlayerMeta struct {
Player PlayerMeta
Valid bool
}
type VillageMeta struct {
ID int64
FullName string
ProfileURL string
}
type Ennoblement struct {
ID int64
Village VillageMeta
NewOwner NullPlayerMeta
OldOwner NullPlayerMeta
CreatedAt time.Time
}
type ServerDoesNotExistError struct {
VersionCode string

View File

@ -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,34 @@ 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
ennoblementsMu sync.Mutex // ennoblementsMu is used by Monitor.fetchEnnoblements
ennoblementsSince map[string]time.Time // ennoblementsSince is used by Monitor.fetchEnnoblements
ennoblementsMaxConcurrentRequests int // ennoblementsMaxConcurrentRequests is used by Monitor.fetchEnnoblements
}
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,
ennoblementsMaxConcurrentRequests int,
) *Monitor {
return &Monitor{
repo: repo,
client: client,
groupSvc: groupSvc,
ennoblementsMaxConcurrentRequests: ennoblementsMaxConcurrentRequests,
ennoblementsSince: make(map[string]time.Time),
}
}
func (m *Monitor) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) {
@ -107,3 +124,244 @@ func (m *Monitor) Delete(ctx context.Context, id, serverID string) error {
return nil
}
func (m *Monitor) Execute(ctx context.Context) ([]domain.EnnoblementNotification, error) {
groups, err := m.groupSvc.List(ctx, domain.ListGroupsParams{})
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 _, r := range res {
for _, g := range groups {
if g.ServerKey != r.serverKey || g.VersionCode != r.versionCode {
continue
}
if g.ChannelGains == "" && g.ChannelLosses == "" {
continue
}
ns, err := m.executeGroup(ctx, g, r.ennoblements)
if err != nil {
// TODO: log this error
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 isInternal(e, monitors) {
continue
}
if g.ChannelGains != "" && isGain(e, monitors) {
notifications = append(notifications, domain.EnnoblementNotification{
Type: domain.EnnoblementNotificationTypeGain,
ServerID: g.ServerID,
ChannelID: g.ChannelGains,
Ennoblement: ennoblementToDomainModel(e),
})
}
if g.ChannelLosses != "" && isLoss(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)
reqLimiter := make(chan struct{}, m.ennoblementsMaxConcurrentRequests)
defer close(reqLimiter)
ennoblementsSince := m.ennoblementsSince
skip := make(map[string]struct{}, len(ennoblementsSince))
for _, g := range groups {
if g.ChannelGains == "" && g.ChannelLosses == "" {
continue
}
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) {
reqLimiter <- struct{}{}
defer func() {
<-reqLimiter
}()
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 {
// TODO: log this error
continue
}
results = append(results, fetchEnnoblementsResult{
versionCode: res.versionCode,
serverKey: res.serverKey,
ennoblements: res.ennoblements,
})
}
return results, nil
}
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.OldOwner.Player.Tribe.Tribe.ID {
o = true
}
}
return n && o
}
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.Tribe.Tribe.ID != e.OldOwner.Player.Tribe.Tribe.ID
}
func isLoss(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var o bool
for _, m := range monitors {
if m.TribeID == e.OldOwner.Player.Tribe.Tribe.ID {
o = true
break
}
}
return o && e.NewOwner.Player.Tribe.Tribe.ID != e.OldOwner.Player.Tribe.Tribe.ID
}
func ennoblementToDomainModel(e twhelp.Ennoblement) domain.Ennoblement {
return domain.Ennoblement{
ID: e.ID,
Village: domain.VillageMeta(e.Village),
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,
},
OldOwner: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.OldOwner.Player.ID,
Name: e.OldOwner.Player.Name,
ProfileURL: e.OldOwner.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.OldOwner.Player.Tribe.Tribe),
Valid: e.OldOwner.Player.Tribe.Valid,
},
},
Valid: e.OldOwner.Valid,
},
CreatedAt: e.CreatedAt,
}
}

View File

@ -2,6 +2,7 @@ package service_test
import (
"context"
"errors"
"testing"
"time"
@ -30,7 +31,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 +56,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 +69,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 +88,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 +101,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 +127,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 +148,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 +162,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 +185,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,
@ -193,3 +194,474 @@ func TestMonitor_Create(t *testing.T) {
})
})
}
func TestMonitor_Execute(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := &mock.FakeTWHelpClient{}
villages := map[string][]twhelp.VillageMeta{
"pl:pl181": {
{
ID: 1,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 2,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 3,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 4,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
"en:en130": {
{
ID: 100,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 101,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
"pl:pl180": {
{
ID: 200,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
}
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(),
},
},
}
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,
},
},
},
}
ennoblements := map[string][]twhelp.Ennoblement{
"pl:pl181": {
{
ID: 1,
Village: villages["pl:pl181"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][1],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 2, // self conquer, should be skipped
Village: villages["pl:pl181"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-4 * time.Minute),
},
{
ID: 3, // internal conquer, should be skipped
Village: villages["pl:pl181"][1],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][2],
Valid: true,
},
CreatedAt: time.Now().Add(-3 * time.Minute),
},
{
ID: 4, // barbarian
Village: villages["pl:pl181"][2],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 5, // disabled notifications about gains, should be skipped
Village: villages["pl:pl181"][3],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][1],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 6, // disabled notifications about losses, should be skipped
Village: villages["pl:pl181"][3],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][3],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
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,
},
OldOwner: twhelp.NullPlayerMeta{},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 101, // no monitor for these tribes, should be skipped
Village: villages["en:en130"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][2],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 102,
Village: villages["en:en130"][1],
NewOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][1],
Valid: true,
},
OldOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][2],
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,
},
OldOwner: twhelp.NullPlayerMeta{},
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: "", // should be skipped - ChannelGains and ChannelLosses are empty strings
ChannelLosses: "",
ServerKey: "pl181",
VersionCode: "pl",
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
ServerID: uuid.NewString(),
ChannelGains: uuid.NewString(),
ChannelLosses: "",
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(),
ServerKey: "pl180",
VersionCode: "pl",
CreatedAt: time.Now(),
},
}
groupSvc.ListReturns(groups, nil)
repo := &mock.FakeMonitorRepository{}
monitors := map[string][]domain.Monitor{
groups[1].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl181"][0].ID,
GroupID: groups[1].ID,
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl181"][2].ID,
GroupID: groups[1].ID,
CreatedAt: time.Now(),
},
},
groups[2].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl181"][1].ID,
GroupID: groups[2].ID,
CreatedAt: time.Now(),
},
},
groups[3].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["en:en130"][1].ID,
GroupID: groups[3].ID,
CreatedAt: time.Now(),
},
},
groups[4].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl180"][0].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, 10).
Execute(context.Background())
assert.NoError(t, err)
expectedNotifications := []domain.EnnoblementNotification{
{
Type: domain.EnnoblementNotificationTypeGain,
ServerID: groups[1].ServerID,
ChannelID: groups[1].ChannelGains,
Ennoblement: ennoblementToDomainModel(ennoblements["pl:pl181"][0]),
},
{
Type: domain.EnnoblementNotificationTypeGain,
ServerID: groups[1].ServerID,
ChannelID: groups[1].ChannelGains,
Ennoblement: ennoblementToDomainModel(ennoblements["pl:pl181"][3]),
},
{
Type: domain.EnnoblementNotificationTypeLoss,
ServerID: groups[2].ServerID,
ChannelID: groups[2].ChannelLosses,
Ennoblement: ennoblementToDomainModel(ennoblements["pl:pl181"][0]),
},
{
Type: domain.EnnoblementNotificationTypeGain,
ServerID: groups[3].ServerID,
ChannelID: groups[3].ChannelGains,
Ennoblement: ennoblementToDomainModel(ennoblements["en:en130"][2]),
},
}
assert.Len(t, notifications, len(expectedNotifications))
for _, n := range expectedNotifications {
assert.Contains(t, notifications, n)
}
})
}
func ennoblementToDomainModel(e twhelp.Ennoblement) domain.Ennoblement {
return domain.Ennoblement{
ID: e.ID,
Village: domain.VillageMeta(e.Village),
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,
},
OldOwner: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.OldOwner.Player.ID,
Name: e.OldOwner.Player.Name,
ProfileURL: e.OldOwner.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.OldOwner.Player.Tribe.Tribe),
Valid: e.OldOwner.Player.Tribe.Valid,
},
},
Valid: e.OldOwner.Valid,
},
CreatedAt: e.CreatedAt,
}
}

View File

@ -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)
}

View File

@ -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,50 @@ func (c *Client) GetTribeByTag(ctx context.Context, version, server, tag string)
return resp.Data, nil
}
type ListEnnoblementsSort string
const (
ListEnnoblementsSortCreatedAtASC ListEnnoblementsSort = "createdAt:ASC"
)
func (s ListEnnoblementsSort) String() string {
return string(s)
}
type ListEnnoblementsQueryParams struct {
Limit int32
Offset int32
Since time.Time
Sort []ListEnnoblementsSort
}
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))
}
for _, s := range queryParams.Sort {
q.Add("sort", s.String())
}
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 {

154
internal/twhelp/twhelp.go Normal file
View File

@ -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"`
}

View File

@ -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)
})
}
}

View File

@ -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"`
}

View File

@ -34,6 +34,8 @@ spec:
key: token
- name: TWHELP_URL
value: "https://tribalwarshelp.com"
- name: BOT_ENNOBLEMENTS_MAX_CONCURRENT_REQUESTS
value: "4"
livenessProbe:
exec:
command: [ "cat", "/tmp/healthy" ]