feat: add a new command - /monitor create (#21)
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #21
This commit is contained in:
Dawid Wysokiński 2022-10-23 06:20:48 +00:00
parent 13a8da3325
commit 58f73e9ca8
25 changed files with 1234 additions and 92 deletions

View File

@ -39,15 +39,17 @@ func New() *cli.Command {
}
groupRepo := bundb.NewGroup(db)
monitorRepo := bundb.NewMonitor(db)
groupSvc := service.NewGroup(groupRepo, client)
monitorSvc := service.NewMonitor(monitorRepo, groupRepo, client)
cfg, err := newBotConfig()
if err != nil {
return fmt.Errorf("newBotConfig: %w", err)
}
bot, err := discord.NewBot(cfg.Token, groupSvc, client)
bot, err := discord.NewBot(cfg.Token, groupSvc, monitorSvc, client)
if err != nil {
return fmt.Errorf("discord.NewBot: %w", err)
}

View File

@ -79,12 +79,35 @@ func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]dom
}
result := make([]domain.Group, 0, len(groups))
for _, version := range groups {
result = append(result, version.ToDomain())
for _, group := range groups {
result = append(result, group.ToDomain())
}
return result, nil
}
func (g *Group) Get(ctx context.Context, id, serverID string) (domain.Group, error) {
if _, err := uuid.Parse(id); err != nil {
return domain.Group{}, domain.GroupNotFoundError{ID: id}
}
var group model.Group
err := 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)
}
return group.ToDomain(), nil
}
func (g *Group) Delete(ctx context.Context, id, serverID string) error {
if _, err := uuid.Parse(id); err != nil {
return domain.GroupNotFoundError{ID: id}

View File

@ -50,7 +50,7 @@ func TestGroup_Update(t *testing.T) {
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewGroup(db)
group := getGroupFromFixture(t, fixture, "group-server-1-1")
group := getGroupFromFixture(t, fixture, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
@ -80,45 +80,47 @@ func TestGroup_Update(t *testing.T) {
assert.Zero(t, updatedGroup)
})
t.Run("ERR: invalid UUID", func(t *testing.T) {
t.Run("ERR: group not found", func(t *testing.T) {
t.Parallel()
id := "12345"
updatedGroup, err := repo.Update(context.Background(), id, "", domain.UpdateGroupParams{
ChannelGains: domain.NullString{
String: "update",
Valid: true,
tests := []struct {
name string
id string
serverID string
}{
{
name: "ID - not UUID",
id: "test",
serverID: group.ServerID,
},
})
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: id})
assert.Zero(t, updatedGroup)
})
t.Run("ERR: group not found (unknown ID)", func(t *testing.T) {
t.Parallel()
id := uuid.NewString()
updatedGroup, err := repo.Update(context.Background(), id, group.ServerID, domain.UpdateGroupParams{
ChannelGains: domain.NullString{
String: "update",
Valid: true,
{
name: "ID - random UUID",
id: uuid.NewString(),
serverID: group.ServerID,
},
})
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: id})
assert.Zero(t, updatedGroup)
})
t.Run("ERR: group not found (unknown ServerID)", func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.Update(context.Background(), group.ID.String(), uuid.NewString(), domain.UpdateGroupParams{
ChannelGains: domain.NullString{
String: "update",
Valid: true,
{
name: "ServerID - random UUID",
id: group.ID.String(),
serverID: uuid.NewString(),
},
})
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: group.ID.String()})
assert.Zero(t, updatedGroup)
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.Update(context.Background(), tt.id, tt.serverID, domain.UpdateGroupParams{
ChannelGains: domain.NullString{
String: "update",
Valid: true,
},
})
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: tt.id})
assert.Zero(t, updatedGroup)
})
}
})
}
@ -180,13 +182,65 @@ func TestGroup_List(t *testing.T) {
}
}
func TestGroup_Get(t *testing.T) {
t.Parallel()
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewGroup(db)
group := getGroupFromFixture(t, fixture, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
})
t.Run("ERR: group not found (unknown ID)", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
serverID string
}{
{
name: "ID - not UUID",
id: "test",
serverID: group.ServerID,
},
{
name: "ID - random UUID",
id: uuid.NewString(),
serverID: group.ServerID,
},
{
name: "ServerID - random UUID",
id: group.ID.String(),
serverID: uuid.NewString(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g, err := repo.Get(context.Background(), tt.id, tt.serverID)
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: tt.id})
assert.Zero(t, g)
})
}
})
}
func TestGroup_Delete(t *testing.T) {
t.Parallel()
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewGroup(db)
group := getGroupFromFixture(t, fixture, "group-server-1-1")
group := getGroupFromFixture(t, fixture, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
@ -200,32 +254,44 @@ func TestGroup_Delete(t *testing.T) {
assert.Len(t, groups, 0)
})
t.Run("ERR: invalid UUID", func(t *testing.T) {
t.Run("ERR: group not found", func(t *testing.T) {
t.Parallel()
id := "12345"
assert.ErrorIs(t, repo.Delete(context.Background(), id, ""), domain.GroupNotFoundError{ID: id})
})
tests := []struct {
name string
id string
serverID string
}{
{
name: "ID - not UUID",
id: "test",
serverID: group.ServerID,
},
{
name: "ID - random UUID",
id: uuid.NewString(),
serverID: group.ServerID,
},
{
name: "ServerID - random UUID",
id: group.ID.String(),
serverID: uuid.NewString(),
},
}
t.Run("ERR: group not found (unknown ID)", func(t *testing.T) {
t.Parallel()
for _, tt := range tests {
tt := tt
id := uuid.NewString()
assert.ErrorIs(
t,
repo.Delete(context.Background(), id, group.ServerID),
domain.GroupNotFoundError{ID: id},
)
})
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
t.Run("ERR: group not found (unknown ServerID)", func(t *testing.T) {
t.Parallel()
assert.ErrorIs(
t,
repo.Delete(context.Background(), group.ID.String(), uuid.NewString()),
domain.GroupNotFoundError{ID: group.ID.String()},
)
assert.ErrorIs(
t,
repo.Delete(context.Background(), tt.id, tt.serverID),
domain.GroupNotFoundError{ID: tt.id},
)
})
}
})
}
@ -233,7 +299,7 @@ func getAllGroupsFromFixture(tb testing.TB, fixture *dbfixture.Fixture) []model.
tb.Helper()
//nolint:lll
ids := []string{"group-server-1-1", "group-server-1-2", "group-server-2-1", "group-server-2-2"}
ids := []string{"group-1-server-1", "group-2-server-1", "group-1-server-2", "group-2-server-2"}
groups := make([]model.Group, 0, len(ids))
for _, id := range ids {

View File

@ -0,0 +1,27 @@
package model
import (
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type Monitor struct {
bun.BaseModel `bun:"base_model,table:monitors,alias:monitor"`
ID uuid.UUID `bun:"id,type:uuid,pk,default:gen_random_uuid()"`
GroupID uuid.UUID `bun:"group_id,type:uuid,notnull,unique:monitors_group_id_tribe_id_key"`
TribeID int64 `bun:"tribe_id,notnull,unique:monitors_group_id_tribe_id_key"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
}
func (m Monitor) ToDomain() domain.Monitor {
return domain.Monitor{
ID: m.ID.String(),
GroupID: m.GroupID.String(),
TribeID: m.TribeID,
CreatedAt: m.CreatedAt,
}
}

View File

@ -0,0 +1,31 @@
package migrations
import (
"context"
"fmt"
"gitea.dwysokinski.me/twhelp/dcbot/internal/bundb/internal/model"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
if _, err := db.NewCreateTable().
Model(&model.Monitor{}).
Varchar(defaultVarcharLength).
ForeignKey(`(group_id) REFERENCES groups (id) ON DELETE CASCADE`).
Exec(ctx); err != nil {
return fmt.Errorf("couldn't create the 'monitors' table: %w", err)
}
return nil
}, func(ctx context.Context, db *bun.DB) error {
if _, err := db.NewDropTable().
Model(&model.Group{}).
IfExists().
Cascade().
Exec(ctx); err != nil {
return fmt.Errorf("couldn't drop the 'monitors' table: %w", err)
}
return nil
})
}

95
internal/bundb/monitor.go Normal file
View File

@ -0,0 +1,95 @@
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/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 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 == "23503" && constraint == "monitors_group_id_fkey":
return domain.GroupDoesNotExistError{
ID: params.GroupID(),
}
case code == "23505" && constraint == "monitors_group_id_tribe_id_key":
return domain.MonitorAlreadyExistsError{
TribeID: params.TribeID(),
GroupID: params.GroupID(),
}
default:
return err
}
}

View File

@ -0,0 +1,140 @@
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()
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewMonitor(db)
t.Run("OK", func(t *testing.T) {
t.Parallel()
group := getGroupFromFixture(t, fixture, "group-1-server-1")
params, err := domain.NewCreateMonitorParams(group.ID.String(), 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()
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: getGroupFromFixture(t, fixture, "group-1-server-1").ID.String(),
expectedMonitors: []string{
"e3017ba8-4fba-4bb1-ac8d-e4e9477a04d2",
"89719460-58ab-46b8-8682-46f161546949",
},
},
{
name: "group-2-server-2",
groupID: getGroupFromFixture(t, fixture, "group-2-server-2").ID.String(),
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)
}
})
}
}

View File

@ -1,26 +1,43 @@
- model: Group
rows:
- _id: group-server-1-1
- _id: group-1-server-1
id: d56ad37f-2637-48ea-98f8-79627f3fcc96
server_id: server-1
server_key: pl181
version_code: pl
created_at: 2022-03-15T15:00:10.000Z
- _id: group-server-1-2
- _id: group-2-server-1
id: 429b790e-7186-4106-b531-4cc4931ce2ba
server_id: server-1
server_key: pl181
version_code: pl
created_at: 2022-03-15T15:03:10.000Z
- _id: group-server-2-1
- _id: group-1-server-2
id: abeb6c8e-70b6-445c-989f-890cd2a1f87a
server_id: server-2
server_key: pl181
version_code: pl
created_at: 2022-03-18T15:03:10.000Z
- _id: group-server-2-2
- _id: group-2-server-2
id: 0be82203-4ca3-4b4c-a0c8-3a70099d88f7
server_id: server-2
server_key: pl181
version_code: pl
created_at: 2022-03-19T15:03:10.000Z
created_at: 2022-03-19T15:03:10.000Z
- model: Monitor
rows:
- _id: monitor-1-group-1-server-1
id: e3017ba8-4fba-4bb1-ac8d-e4e9477a04d2
group_id: d56ad37f-2637-48ea-98f8-79627f3fcc96
tribe_id: 123
created_at: 2022-03-15T15:01:10.000Z
- _id: monitor-2-group-1-server-1
id: 89719460-58ab-46b8-8682-46f161546949
group_id: d56ad37f-2637-48ea-98f8-79627f3fcc96
tribe_id: 15
created_at: 2022-03-15T15:02:10.000Z
- _id: monitor-1-group-2-server-2
id: c7d63c3d-55f6-432e-b9d8-006f8c5ab407
group_id: 0be82203-4ca3-4b4c-a0c8-3a70099d88f7
tribe_id: 121
created_at: 2022-03-19T15:10:10.000Z

View File

@ -17,36 +17,52 @@ type GroupService interface {
Delete(ctx context.Context, id, serverID string) error
}
type MonitorService interface {
Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error)
}
type TWHelpClient interface {
ListVersions(ctx context.Context) ([]twhelp.Version, error)
}
type Bot struct {
s *discordgo.Session
groupSvc GroupService
client TWHelpClient
s *discordgo.Session
groupSvc GroupService
monitorSvc MonitorService
client TWHelpClient
}
func NewBot(token string, groupSvc GroupService, client TWHelpClient) (*Bot, error) {
func NewBot(token string, groupSvc GroupService, monitorSvc MonitorService, client TWHelpClient) (*Bot, error) {
s, err := discordgo.New("Bot " + token)
if err != nil {
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, client: client}
b := &Bot{s: s, groupSvc: groupSvc, monitorSvc: monitorSvc, client: client}
if err = b.registerCommands(); err != nil {
_ = s.Close()
return nil, fmt.Errorf("couldn't register commands: %w", err)
}
return b, nil
}
func (b *Bot) registerCommands() error {
if err := b.registerCommand(&groupCommand{svc: b.groupSvc, client: b.client}); err != nil {
return err
commands := []command{
&groupCommand{svc: b.groupSvc, client: b.client},
&monitorCommand{svc: b.monitorSvc},
}
for _, c := range commands {
if err := b.registerCommand(c); err != nil {
return err
}
}
return nil
}

View File

@ -35,9 +35,10 @@ func (c *groupCommand) create(s *discordgo.Session) error {
}
var perm int64 = discordgo.PermissionAdministrator
_, err = s.ApplicationCommandCreate(s.State.User.ID, "", &discordgo.ApplicationCommand{
Name: c.name(),
Description: "Manages monitor groups on this server",
Description: "Manages groups on this server",
DefaultMemberPermissions: &perm,
Options: []*discordgo.ApplicationCommandOption{
{
@ -185,6 +186,7 @@ func (c *groupCommand) create(s *discordgo.Session) error {
if err != nil {
return fmt.Errorf("s.ApplicationCommandCreate: %w", err)
}
return nil
}

View File

@ -0,0 +1,118 @@
package discord
import (
"context"
"fmt"
"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
_, err := s.ApplicationCommandCreate(s.State.User.ID, "", &discordgo.ApplicationCommand{
Name: c.name(),
Description: "Manages monitors",
DefaultMemberPermissions: &perm,
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,
},
},
},
},
})
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
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 + ")",
},
})
}

View File

@ -1,9 +1,13 @@
package domain
import "errors"
import (
"errors"
"fmt"
)
var (
ErrNothingToUpdate = errors.New("nothing to update")
ErrRequired = errors.New("cannot be blank")
)
type ErrorCode uint8
@ -12,6 +16,7 @@ const (
ErrorCodeUnknown ErrorCode = iota
ErrorCodeEntityNotFound
ErrorCodeValidationError
ErrorCodeAlreadyExists
)
type UserError interface {
@ -20,18 +25,39 @@ type UserError interface {
Code() ErrorCode
}
type RequiredError struct {
type ValidationError struct {
Field string
Err error
}
func (e RequiredError) Error() string {
return e.Field + ": cannot be blank"
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Err)
}
func (e RequiredError) UserError() string {
func (e ValidationError) UserError() string {
return e.Error()
}
func (e RequiredError) Code() ErrorCode {
func (e ValidationError) Code() ErrorCode {
return ErrorCodeValidationError
}
func (e ValidationError) Unwrap() error {
return e.Err
}
type MinError struct {
Min int
}
func (e MinError) Error() string {
return fmt.Sprintf("must be no less than %d", e.Min)
}
func (e MinError) UserError() string {
return e.Error()
}
func (e MinError) Code() ErrorCode {
return ErrorCodeValidationError
}

View File

@ -1,20 +1,37 @@
package domain_test
import (
"fmt"
"testing"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestRequiredError(t *testing.T) {
func TestValidationError(t *testing.T) {
t.Parallel()
err := domain.RequiredError{
Field: "123",
err := domain.ValidationError{
Field: "test",
Err: domain.MinError{
Min: 25,
},
}
var _ domain.UserError = err
assert.Equal(t, err.Field+": cannot be blank", err.Error())
assert.Equal(t, fmt.Sprintf("%s: %s", err.Field, err.Err.Error()), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.ErrorIs(t, err, err.Err)
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
func TestMinError(t *testing.T) {
t.Parallel()
err := domain.MinError{
Min: 25,
}
var _ domain.UserError = err
assert.Equal(t, "must be no less than 25", err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}

View File

@ -25,20 +25,23 @@ type CreateGroupParams struct {
func NewCreateGroupParams(serverID, versionCode, serverKey, channelGains, channelLosses string) (CreateGroupParams, error) {
if serverID == "" {
return CreateGroupParams{}, RequiredError{
return CreateGroupParams{}, ValidationError{
Field: "ServerID",
Err: ErrRequired,
}
}
if versionCode == "" {
return CreateGroupParams{}, RequiredError{
return CreateGroupParams{}, ValidationError{
Field: "VersionCode",
Err: ErrRequired,
}
}
if serverKey == "" {
return CreateGroupParams{}, RequiredError{
return CreateGroupParams{}, ValidationError{
Field: "ServerKey",
Err: ErrRequired,
}
}
@ -117,3 +120,19 @@ func (e GroupNotFoundError) UserError() string {
func (e GroupNotFoundError) Code() ErrorCode {
return ErrorCodeEntityNotFound
}
type GroupDoesNotExistError struct {
ID string
}
func (e GroupDoesNotExistError) Error() string {
return fmt.Sprintf("group (ID=%s) doesn't exist", e.ID)
}
func (e GroupDoesNotExistError) UserError() string {
return e.Error()
}
func (e GroupDoesNotExistError) Code() ErrorCode {
return ErrorCodeValidationError
}

View File

@ -34,20 +34,29 @@ func TestNewCreateGroupParams(t *testing.T) {
name: "ERR: ServerID cannot be blank",
versionCode: "",
serverID: "",
err: domain.RequiredError{Field: "ServerID"},
err: domain.ValidationError{
Field: "ServerID",
Err: domain.ErrRequired,
},
},
{
name: "ERR: VersionCode cannot be blank",
serverID: "1234",
versionCode: "",
err: domain.RequiredError{Field: "VersionCode"},
err: domain.ValidationError{
Field: "VersionCode",
Err: domain.ErrRequired,
},
},
{
name: "ERR: ServerKey cannot be blank",
serverID: "1234",
versionCode: "en",
serverKey: "",
err: domain.RequiredError{Field: "ServerKey"},
err: domain.ValidationError{
Field: "ServerKey",
Err: domain.ErrRequired,
},
},
}
@ -163,3 +172,15 @@ func TestGroupNotFoundError(t *testing.T) {
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code())
}
func TestGroupDoesNotExistError(t *testing.T) {
t.Parallel()
err := domain.GroupDoesNotExistError{
ID: uuid.NewString(),
}
var _ domain.UserError = err
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())
}

View File

@ -0,0 +1,84 @@
package domain
import (
"fmt"
"time"
)
const (
tribeIDMin = 1
)
type Monitor struct {
ID string
TribeID int64
GroupID string
CreatedAt time.Time
}
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)
}
func (e MonitorAlreadyExistsError) UserError() string {
return e.Error()
}
func (e MonitorAlreadyExistsError) Code() ErrorCode {
return ErrorCodeAlreadyExists
}
type MonitorLimitReachedError struct {
Current int // current number of groups
Limit int // maximum number of groups
}
func (e MonitorLimitReachedError) Error() string {
return fmt.Sprintf("monitor limit has been reached (%d/%d)", e.Current, e.Limit)
}
func (e MonitorLimitReachedError) UserError() string {
return e.Error()
}
func (e MonitorLimitReachedError) Code() ErrorCode {
return ErrorCodeValidationError
}

View File

@ -0,0 +1,93 @@
package domain_test
import (
"fmt"
"testing"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/google/uuid"
"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()
err := domain.MonitorAlreadyExistsError{
GroupID: uuid.NewString(),
TribeID: 1234,
}
var _ domain.UserError = err
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())
}
func TestMonitorLimitReachedError(t *testing.T) {
t.Parallel()
err := domain.MonitorLimitReachedError{
Current: 10,
Limit: 10,
}
var _ domain.UserError = err
assert.Equal(t, fmt.Sprintf("monitor limit has been reached (%d/%d)", err.Current, err.Limit), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}

View File

@ -35,3 +35,19 @@ func (e ServerIsClosedError) UserError() string {
func (e ServerIsClosedError) Code() ErrorCode {
return ErrorCodeValidationError
}
type TribeDoesNotExistError struct {
Tag string
}
func (e TribeDoesNotExistError) Error() string {
return fmt.Sprintf("tribe (Tag=%s) doesn't exist", e.Tag)
}
func (e TribeDoesNotExistError) UserError() string {
return e.Error()
}
func (e TribeDoesNotExistError) Code() ErrorCode {
return ErrorCodeValidationError
}

View File

@ -33,3 +33,15 @@ func TestServerIsClosedError(t *testing.T) {
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
func TestTribeDoesNotExistError(t *testing.T) {
t.Parallel()
err := domain.TribeDoesNotExistError{
Tag: "*TAG*",
}
var _ domain.UserError = err
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())
}

View File

@ -18,6 +18,7 @@ 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
}
@ -91,6 +92,14 @@ func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]dom
return groups, nil
}
func (g *Group) Get(ctx context.Context, id, serverID string) (domain.Group, error) {
group, err := g.repo.Get(ctx, id, serverID)
if err != nil {
return domain.Group{}, fmt.Errorf("GroupRepository.Get: %w", err)
}
return group, nil
}
func (g *Group) Delete(ctx context.Context, id, serverID string) error {
if err := g.repo.Delete(ctx, id, serverID); err != nil {
return fmt.Errorf("GroupRepository.Delete: %w", err)

View File

@ -0,0 +1,89 @@
package service
import (
"context"
"errors"
"fmt"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
)
const (
maxMonitorsPerGroup = 10
)
//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)
}
//counterfeiter:generate -o internal/mock/group_getter.gen.go . GroupGetter
type GroupGetter interface {
Get(ctx context.Context, id, serverID string) (domain.Group, error)
}
type Monitor struct {
repo MonitorRepository
client TWHelpClient
groupSvc GroupGetter
}
func NewMonitor(repo MonitorRepository, groupSvc GroupGetter, client TWHelpClient) *Monitor {
return &Monitor{repo: repo, client: client, groupSvc: groupSvc}
}
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) >= maxMonitorsPerGroup {
return domain.Monitor{}, domain.MonitorLimitReachedError{
Current: len(monitors),
Limit: maxMonitorsPerGroup,
}
}
// check if tribe exists
tribe, err := m.client.GetTribeByTag(ctx, group.VersionCode, group.ServerKey, tribeTag)
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 !tribe.DeletedAt.IsZero() {
return domain.Monitor{}, domain.TribeDoesNotExistError{
Tag: tribeTag,
}
}
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
}

View File

@ -0,0 +1,195 @@
package service_test
import (
"context"
"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"
)
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.FakeGroupGetter{}
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.GetTribeByTagReturns(tribe, nil)
groupID := uuid.NewString()
monitor, err := service.NewMonitor(repo, groupSvc, client).
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.FakeGroupGetter{}
groupSvc.GetReturns(domain.Group{}, domain.GroupNotFoundError{ID: groupID})
monitor, err := service.NewMonitor(nil, groupSvc, nil).
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.FakeGroupGetter{}
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).
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()
t.Run("API error", 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.FakeGroupGetter{}
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.GetTribeByTagReturns(twhelp.Tribe{}, twhelp.APIError{
Code: twhelp.ErrorCodeEntityNotFound,
Message: "tribe not found",
})
tag := "TAG"
monitor, err := service.NewMonitor(repo, groupSvc, client).
Create(context.Background(), uuid.NewString(), uuid.NewString(), tag)
assert.ErrorIs(t, err, domain.TribeDoesNotExistError{
Tag: tag,
})
assert.Zero(t, monitor)
})
t.Run("tribe is deleted", func(t *testing.T) {
t.Parallel()
repo := &mock.FakeMonitorRepository{}
repo.ListReturns(nil, nil)
groupSvc := &mock.FakeGroupGetter{}
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.Now(),
}
client.GetTribeByTagReturns(tribe, nil)
monitor, err := service.NewMonitor(repo, groupSvc, client).
Create(context.Background(), uuid.NewString(), uuid.NewString(), tribe.Tag)
assert.ErrorIs(t, err, domain.TribeDoesNotExistError{
Tag: tribe.Tag,
})
assert.Zero(t, monitor)
})
})
}

View File

@ -11,4 +11,5 @@ import (
//counterfeiter:generate -o internal/mock/twhelp_client.gen.go . TWHelpClient
type TWHelpClient interface {
GetServer(ctx context.Context, version, server string) (twhelp.Server, error)
GetTribeByTag(ctx context.Context, version, server, tag string) (twhelp.Tribe, error)
}

View File

@ -14,8 +14,9 @@ const (
defaultUserAgent = "TWHelpDCBot/development"
defaultTimeout = 10 * time.Second
endpointListVersions = "/api/v1/versions"
endpointGetServer = "/api/v1/versions/%s/servers/%s"
endpointListVersions = "/api/v1/versions"
endpointGetServer = "/api/v1/versions/%s/servers/%s"
endpointGetTribeByTag = "/api/v1/versions/%s/servers/%s/tribes/tag/%s"
)
type Client struct {
@ -68,6 +69,14 @@ func (c *Client) GetServer(ctx context.Context, version, server string) (Server,
return resp.Data, nil
}
func (c *Client) GetTribeByTag(ctx context.Context, version, server, tag string) (Tribe, error) {
var resp getTribeResp
if err := c.getJSON(ctx, fmt.Sprintf(endpointGetTribeByTag, version, server, tag), &resp); err != nil {
return Tribe{}, 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 {

View File

@ -1,5 +1,7 @@
package twhelp
import "time"
type ErrorCode string
const (
@ -47,3 +49,15 @@ type Server struct {
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"`
}