feat: add a new endpoint - GET /api/v1/versions/:code/servers/:key/players/:id/history (#152)
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: twhelp/core#152
This commit is contained in:
Dawid Wysokiński 2023-01-10 06:16:28 +00:00
parent 298f3fed02
commit c2a35d4c98
26 changed files with 1684 additions and 248 deletions

View File

@ -101,6 +101,7 @@ func newServer(cfg serverConfig) (*http.Server, error) {
villageRepo := bundb.NewVillage(cfg.db)
ennoblementRepo := bundb.NewEnnoblement(cfg.db)
tribeChangeRepo := bundb.NewTribeChange(cfg.db)
playerSnapshotRepo := bundb.NewPlayerSnapshot(cfg.db)
// services
versionSvc := service.NewVersion(versionRepo)
@ -110,17 +111,19 @@ func newServer(cfg serverConfig) (*http.Server, error) {
playerSvc := service.NewPlayer(playerRepo, tribeChangeSvc, client)
villageSvc := service.NewVillage(villageRepo, client)
ennoblementSvc := service.NewEnnoblement(ennoblementRepo, client)
playerSnapshotSvc := service.NewPlayerSnapshot(playerSnapshotRepo, playerSvc)
// router
r := chi.NewRouter()
r.Use(getChiMiddlewares(cfg.logger)...)
r.Mount("/api", rest.NewRouter(rest.RouterConfig{
VersionService: versionSvc,
ServerService: serverSvc,
TribeService: tribeSvc,
PlayerService: playerSvc,
VillageService: villageSvc,
EnnoblementService: ennoblementSvc,
VersionService: versionSvc,
ServerService: serverSvc,
TribeService: tribeSvc,
PlayerService: playerSvc,
VillageService: villageSvc,
EnnoblementService: ennoblementSvc,
PlayerSnapshotService: playerSnapshotSvc,
CORS: rest.CORSConfig{
Enabled: apiCfg.CORSEnabled,
AllowedOrigins: apiCfg.CORSAllowedOrigins,

View File

@ -16,6 +16,7 @@ type PlayerSnapshot struct {
Points int64 `bun:"points,default:0"`
Rank int64 `bun:"rank,default:0"`
TribeID int64 `bun:"tribe_id,nullzero"`
Tribe Tribe `bun:"tribe,rel:belongs-to,join:tribe_id=id,join:server_key=server_key"`
ServerKey string `bun:"server_key,notnull,nullzero,type:varchar(100),unique:player_snapshots_player_id_server_key_date_key"`
Date time.Time `bun:"date,notnull,nullzero,type:date,unique:player_snapshots_player_id_server_key_date_key"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
@ -37,17 +38,32 @@ func NewPlayerSnapshot(p domain.CreatePlayerSnapshotParams) PlayerSnapshot {
}
}
func (ps PlayerSnapshot) ToDomain() domain.PlayerSnapshot {
func (p PlayerSnapshot) ToDomain() domain.PlayerSnapshot {
return domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated(ps.OpponentsDefeated),
ID: ps.ID,
PlayerID: ps.PlayerID,
NumVillages: ps.NumVillages,
Points: ps.Points,
Rank: ps.Rank,
TribeID: ps.TribeID,
ServerKey: ps.ServerKey,
Date: ps.Date,
CreatedAt: ps.CreatedAt,
OpponentsDefeated: domain.OpponentsDefeated(p.OpponentsDefeated),
ID: p.ID,
PlayerID: p.PlayerID,
NumVillages: p.NumVillages,
Points: p.Points,
Rank: p.Rank,
TribeID: p.TribeID,
ServerKey: p.ServerKey,
Date: p.Date,
CreatedAt: p.CreatedAt,
}
}
func (p PlayerSnapshot) ToDomainWithRelations() domain.PlayerSnapshotWithRelations {
res := domain.PlayerSnapshotWithRelations{
PlayerSnapshot: p.ToDomain(),
}
if p.Tribe.ID > 0 {
res.Tribe = domain.NullTribeMeta{
Valid: true,
Tribe: p.Tribe.ToMeta(),
}
}
return res
}

View File

@ -48,4 +48,14 @@ func TestPlayerSnapshot(t *testing.T) {
assert.Equal(t, params.ServerKey, snapshot.ServerKey)
assert.Equal(t, params.Date, snapshot.Date)
assert.WithinDuration(t, time.Now(), snapshot.CreatedAt, 10*time.Millisecond)
result.Tribe = model.Tribe{
ID: params.TribeID,
}
snapshotWithRelations := result.ToDomainWithRelations()
assert.Equal(t, snapshot, snapshotWithRelations.PlayerSnapshot)
assert.WithinDuration(t, time.Now(), snapshotWithRelations.CreatedAt, 10*time.Millisecond)
assert.True(t, snapshotWithRelations.Tribe.Valid)
assert.Equal(t, params.TribeID, snapshotWithRelations.Tribe.Tribe.ID)
}

View File

@ -108,10 +108,6 @@ func (p *Player) ListCountWithRelations(ctx context.Context, params domain.ListP
paramsApplier := listPlayersParamsApplier{params}
cntQ := p.db.NewSelect().
Model(&model.Player{}).
Apply(paramsApplier.applyFilters)
subQ := p.db.NewSelect().
Column("id", "server_key").
Model(&model.Player{}).
@ -121,18 +117,24 @@ func (p *Player) ListCountWithRelations(ctx context.Context, params domain.ListP
return nil, 0, err
}
cntQ := p.db.NewSelect().
Model(&model.Player{}).
Apply(paramsApplier.applyFilters)
otherServersQ := p.db.NewSelect().
Model(&model.Player{}).
ExcludeColumn("*").
ColumnExpr("jsonb_agg(jsonb_build_object('key', player2.server_key, 'profileUrl', player2.profile_url, 'deletedAt', player2.deleted_at))").
ModelTableExpr("?TableName as player2").
Join("INNER JOIN servers AS server2 ON server2.key = player2.server_key").
Where("player2.id = player.id").
Where("server2.version_code = server.version_code").
Where("server2.key != server.key")
q := p.db.NewSelect().
Model(&players).
ColumnExpr("?TableColumns").
ColumnExpr("(?) AS other_servers", p.db.NewSelect().
Model(&model.Player{}).
ExcludeColumn("*").
ColumnExpr("jsonb_agg(jsonb_build_object('key', player2.server_key, 'profileUrl', player2.profile_url, 'deletedAt', player2.deleted_at))").
ModelTableExpr("?TableName as player2").
Join("INNER JOIN servers AS server2 ON server2.key = player2.server_key").
Where("player2.id = player.id").
Where("server2.version_code = server.version_code").
Where("server2.key != server.key")).
ColumnExpr("(?) AS other_servers", otherServersQ).
Order(playerOrders...).
Where("(player.id, player.server_key) IN (?)", subQ).
Relation("Tribe", func(q *bun.SelectQuery) *bun.SelectQuery {

View File

@ -12,6 +12,10 @@ import (
"github.com/uptrace/bun"
)
var (
playerSnapshotOrders = []string{"ps.server_key ASC"}
)
type PlayerSnapshot struct {
db *bun.DB
}
@ -43,11 +47,15 @@ func (p *PlayerSnapshot) Create(ctx context.Context, params ...domain.CreatePlay
func (p *PlayerSnapshot) List(ctx context.Context, params domain.ListPlayerSnapshotsParams) ([]domain.PlayerSnapshot, error) {
var snapshots []model.PlayerSnapshot
if err := p.db.NewSelect().
q := p.db.NewSelect().
Model(&snapshots).
Order("server_key ASC", "date ASC", "id ASC").
Apply(listPlayerSnapshotsParamsApplier{params}.apply).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
Order(playerSnapshotOrders...)
q, err := listPlayerSnapshotsParamsApplier{params}.apply(q)
if err != nil {
return nil, fmt.Errorf("listPlayerSnapshotsParamsApplier.apply: %w", err)
}
if err = q.Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("couldn't select player snapshots from the db: %w", err)
}
@ -59,6 +67,52 @@ func (p *PlayerSnapshot) List(ctx context.Context, params domain.ListPlayerSnaps
return result, nil
}
func (p *PlayerSnapshot) ListCountWithRelations(
ctx context.Context,
params domain.ListPlayerSnapshotsParams,
) ([]domain.PlayerSnapshotWithRelations, int64, error) {
var snapshots []model.PlayerSnapshot
paramsApplier := listPlayerSnapshotsParamsApplier{params}
subQ := p.db.NewSelect().
Column("id").
Model(&model.PlayerSnapshot{}).
Order(playerSnapshotOrders...)
subQ, err := paramsApplier.apply(subQ)
if err != nil {
return nil, 0, err
}
cntQ := p.db.NewSelect().
Model(&model.PlayerSnapshot{}).
Apply(paramsApplier.applyFilters)
q := p.db.NewSelect().
Model(&snapshots).
Order(playerSnapshotOrders...).
Where("ps.id IN (?)", subQ).
Relation("Tribe", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Column(tribeMetaColumns...)
})
q, err = paramsApplier.applySort(q)
if err != nil {
return nil, 0, err
}
count, err := scanAndCount(ctx, cntQ, q)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, 0, fmt.Errorf("couldn't select player snapshots from the db: %w", err)
}
result := make([]domain.PlayerSnapshotWithRelations, 0, len(snapshots))
for _, snapshot := range snapshots {
result = append(result, snapshot.ToDomainWithRelations())
}
return result, int64(count), nil
}
func (p *PlayerSnapshot) Delete(ctx context.Context, serverKey string, createdAtLTE time.Time) error {
if _, err := p.db.NewDelete().
Model(&model.PlayerSnapshot{}).
@ -76,8 +130,12 @@ type listPlayerSnapshotsParamsApplier struct {
params domain.ListPlayerSnapshotsParams
}
func (l listPlayerSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
return l.applyPagination(l.applyFilters(q))
func (l listPlayerSnapshotsParamsApplier) apply(q *bun.SelectQuery) (*bun.SelectQuery, error) {
q, err := l.applySort(q)
if err != nil {
return nil, err
}
return l.applyPagination(l.applyFilters(q)), nil
}
func (l listPlayerSnapshotsParamsApplier) applyFilters(q *bun.SelectQuery) *bun.SelectQuery {
@ -85,9 +143,46 @@ func (l listPlayerSnapshotsParamsApplier) applyFilters(q *bun.SelectQuery) *bun.
q = q.Where("ps.server_key IN (?)", bun.In(l.params.ServerKeys))
}
if l.params.PlayerIDs != nil {
q = q.Where("ps.player_id IN (?)", bun.In(l.params.PlayerIDs))
}
return q
}
func (l listPlayerSnapshotsParamsApplier) applySort(q *bun.SelectQuery) (*bun.SelectQuery, error) {
if len(l.params.Sort) == 0 {
return q, nil
}
orders := make([]string, 0, len(l.params.Sort))
for i, s := range l.params.Sort {
column, err := playerSnapshotSortByToColumn(s.By)
if err != nil {
return nil, fmt.Errorf("playerSnapshotSortByToColumn (index=%d): %w", i, err)
}
direction, err := sortDirectionToString(s.Direction)
if err != nil {
return nil, fmt.Errorf("sortDirectionToString (index=%d): %w", i, err)
}
orders = append(orders, column+" "+direction)
}
return q.Order(orders...), nil
}
func (l listPlayerSnapshotsParamsApplier) applyPagination(q *bun.SelectQuery) *bun.SelectQuery {
return (paginationApplier{pagination: l.params.Pagination}).apply(q)
}
func playerSnapshotSortByToColumn(sortBy domain.PlayerSnapshotSortBy) (string, error) {
switch sortBy {
case domain.PlayerSnapshotSortByID:
return "ps.id", nil
case domain.PlayerSnapshotSortByDate:
return "ps.date", nil
}
return "", fmt.Errorf("%w: %d", domain.ErrUnsupportedSortBy, sortBy)
}

View File

@ -217,26 +217,61 @@ func TestPlayerSnapshot_List(t *testing.T) {
name string
params domain.ListPlayerSnapshotsParams
expectedSnapshots []expectedSnapshots
expectedCount int64
expectedErr error
}{
{
name: "Empty struct",
params: domain.ListPlayerSnapshotsParams{},
name: "Sort=[{By=Date,Direction=ASC},{By=ID,Direction=ASC}]",
params: domain.ListPlayerSnapshotsParams{
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
},
},
expectedSnapshots: allSnapshots,
expectedCount: int64(len(allSnapshots)),
},
{
name: "ServerKey=[de188]",
name: "ServerKey=[de188],Sort=[{By=Date,Direction=ASC},{By=ID,Direction=ASC}]",
params: domain.ListPlayerSnapshotsParams{
ServerKeys: []string{"de188"},
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
},
},
expectedSnapshots: snapshotsDE188,
expectedCount: int64(len(snapshotsDE188)),
},
{
name: "ServerKey=[pl169],Limit=2",
name: "ServerKey=[pl169],Sort=[{By=Date,Direction=ASC},{By=ID,Direction=ASC}],Limit=2",
params: domain.ListPlayerSnapshotsParams{
ServerKeys: []string{"pl169"},
Pagination: domain.Pagination{
Limit: 2,
},
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
},
},
expectedSnapshots: []expectedSnapshots{
{
@ -248,14 +283,25 @@ func TestPlayerSnapshot_List(t *testing.T) {
serverKey: "pl169",
},
},
expectedCount: 3,
},
{
name: "ServerKey=[pl169],Offset=1",
name: "ServerKey=[pl169],Sort=[{By=Date,Direction=ASC},{By=ID,Direction=ASC}],Offset=1",
params: domain.ListPlayerSnapshotsParams{
ServerKeys: []string{"pl169"},
Pagination: domain.Pagination{
Offset: 1,
},
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
},
},
expectedSnapshots: []expectedSnapshots{
{
@ -267,14 +313,48 @@ func TestPlayerSnapshot_List(t *testing.T) {
serverKey: "pl169",
},
},
expectedCount: 3,
},
{
name: "ServerKey=[pl169],Offset=1,Limit=1",
name: "ServerKey=[pl169],Sort=[{By=Date,Direction=DESC},{By=ID,Direction=ASC}],Offset=2,Limit=1",
params: domain.ListPlayerSnapshotsParams{
ServerKeys: []string{"pl169"},
Pagination: domain.Pagination{
Limit: 1,
Offset: 1,
Offset: 2,
},
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionDESC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
},
},
expectedSnapshots: []expectedSnapshots{
{
id: 100000,
serverKey: "pl169",
},
},
expectedCount: 3,
},
{
name: "ServerKey=[pl169],PlayerIDs=[6180190],Sort=[{By=ID,Direction=DESC}],Limit=1",
params: domain.ListPlayerSnapshotsParams{
ServerKeys: []string{"pl169"},
PlayerIDs: []int64{6180190},
Pagination: domain.Pagination{
Limit: 1,
},
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionDESC,
},
},
},
expectedSnapshots: []expectedSnapshots{
@ -283,6 +363,29 @@ func TestPlayerSnapshot_List(t *testing.T) {
serverKey: "pl169",
},
},
expectedCount: 2,
},
{
name: "ERR: unsupported sort by",
params: domain.ListPlayerSnapshotsParams{
Sort: []domain.PlayerSnapshotSort{
{By: 100, Direction: domain.SortDirectionDESC},
},
},
expectedSnapshots: nil,
expectedCount: 0,
expectedErr: domain.ErrUnsupportedSortBy,
},
{
name: "ERR: unsupported sort direction",
params: domain.ListPlayerSnapshotsParams{
Sort: []domain.PlayerSnapshotSort{
{By: domain.PlayerSnapshotSortByDate, Direction: 100},
},
},
expectedSnapshots: nil,
expectedCount: 0,
expectedErr: domain.ErrUnsupportedSortDirection,
},
}
@ -292,19 +395,41 @@ func TestPlayerSnapshot_List(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
res, err := repo.List(context.Background(), tt.params)
assert.NoError(t, err)
assert.Len(t, res, len(tt.expectedSnapshots))
resListCountWithRelations, count, err := repo.ListCountWithRelations(context.Background(), tt.params)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.expectedCount, count)
assert.Len(t, resListCountWithRelations, len(tt.expectedSnapshots))
for _, expSnapshot := range tt.expectedSnapshots {
found := false
for _, snapshot := range res {
if snapshot.ID == expSnapshot.id && snapshot.ServerKey == expSnapshot.serverKey {
found = true
break
for _, snapshot := range resListCountWithRelations {
if snapshot.ID != expSnapshot.id {
continue
}
if snapshot.ServerKey != expSnapshot.serverKey {
continue
}
if snapshot.TribeID != 0 && (!snapshot.Tribe.Valid || snapshot.Tribe.Tribe.ID != snapshot.TribeID) {
continue
}
if snapshot.TribeID == 0 && snapshot.Tribe.Valid {
continue
}
found = true
break
}
assert.True(t, found, "snapshot (id=%d,serverkey=%s) not found", expSnapshot.id, expSnapshot.serverKey)
}
resList, err := repo.List(context.Background(), tt.params)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Len(t, resList, len(resListCountWithRelations))
for i, snapshot := range resList {
assert.Equal(t, resListCountWithRelations[i].PlayerSnapshot, snapshot)
}
})
}
}

View File

@ -10,37 +10,30 @@ import (
func TestNewEnnoblementSortBy(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
tests := []struct {
s string
output domain.EnnoblementSortBy
expectedErr error
}{
{
s: "createdAt",
output: domain.EnnoblementSortByCreatedAt,
},
{
s: "unsupported",
expectedErr: domain.ErrUnsupportedSortBy,
},
}
tests := []struct {
s string
output domain.EnnoblementSortBy
}{
{
s: "createdAt",
output: domain.EnnoblementSortByCreatedAt,
},
}
for _, tt := range tests {
tt := tt
for _, tt := range tests {
tt := tt
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
res, err := domain.NewEnnoblementSortBy(tt.s)
assert.NoError(t, err)
assert.Equal(t, tt.output, res)
})
}
})
t.Run("ERR: unsupported sort by", func(t *testing.T) {
t.Parallel()
res, err := domain.NewEnnoblementSortBy("unsupported")
assert.ErrorIs(t, err, domain.ErrUnsupportedSortBy)
assert.Zero(t, res)
})
res, err := domain.NewEnnoblementSortBy(tt.s)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.output, res)
})
}
}

View File

@ -10,63 +10,56 @@ import (
func TestNewPlayerSortBy(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
tests := []struct {
s string
output domain.PlayerSortBy
expectedErr error
}{
{
s: "id",
output: domain.PlayerSortByID,
},
{
s: "scoreAtt",
output: domain.PlayerSortByScoreAtt,
},
{
s: "scoreDef",
output: domain.PlayerSortByScoreDef,
},
{
s: "scoreSup",
output: domain.PlayerSortByScoreSup,
},
{
s: "scoreTotal",
output: domain.PlayerSortByScoreTotal,
},
{
s: "points",
output: domain.PlayerSortByPoints,
},
{
s: "deletedAt",
output: domain.PlayerSortByDeletedAt,
},
{
s: "unsupported",
expectedErr: domain.ErrUnsupportedSortBy,
},
}
tests := []struct {
s string
output domain.PlayerSortBy
}{
{
s: "id",
output: domain.PlayerSortByID,
},
{
s: "scoreAtt",
output: domain.PlayerSortByScoreAtt,
},
{
s: "scoreDef",
output: domain.PlayerSortByScoreDef,
},
{
s: "scoreSup",
output: domain.PlayerSortByScoreSup,
},
{
s: "scoreTotal",
output: domain.PlayerSortByScoreTotal,
},
{
s: "points",
output: domain.PlayerSortByPoints,
},
{
s: "deletedAt",
output: domain.PlayerSortByDeletedAt,
},
}
for _, tt := range tests {
tt := tt
for _, tt := range tests {
tt := tt
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
res, err := domain.NewPlayerSortBy(tt.s)
assert.NoError(t, err)
assert.Equal(t, tt.output, res)
})
}
})
t.Run("ERR: unsupported sort by", func(t *testing.T) {
t.Parallel()
res, err := domain.NewPlayerSortBy("unsupported")
assert.ErrorIs(t, err, domain.ErrUnsupportedSortBy)
assert.Zero(t, res)
})
res, err := domain.NewPlayerSortBy(tt.s)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.output, res)
})
}
}
func TestPlayerNotFoundError(t *testing.T) {

View File

@ -1,6 +1,9 @@
package domain
import "time"
import (
"fmt"
"time"
)
type PlayerSnapshot struct {
OpponentsDefeated
@ -16,6 +19,12 @@ type PlayerSnapshot struct {
CreatedAt time.Time
}
type PlayerSnapshotWithRelations struct {
PlayerSnapshot
Tribe NullTribeMeta
}
type CreatePlayerSnapshotParams struct {
OpponentsDefeated
@ -28,9 +37,33 @@ type CreatePlayerSnapshotParams struct {
Date time.Time
}
type PlayerSnapshotSortBy uint8
const (
PlayerSnapshotSortByID PlayerSnapshotSortBy = iota
PlayerSnapshotSortByDate
)
func NewPlayerSnapshotSortBy(s string) (PlayerSnapshotSortBy, error) {
switch s {
case "id":
return PlayerSnapshotSortByID, nil
case "date":
return PlayerSnapshotSortByDate, nil
}
return 0, fmt.Errorf("%w: \"%s\"", ErrUnsupportedSortBy, s)
}
type PlayerSnapshotSort struct {
By PlayerSnapshotSortBy
Direction SortDirection
}
type ListPlayerSnapshotsParams struct {
ServerKeys []string
PlayerIDs []int64
Pagination Pagination
Sort []PlayerSnapshotSort
}
type TribeSnapshot struct {

View File

@ -0,0 +1,43 @@
package domain_test
import (
"testing"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestNewPlayerSnapshotSortBy(t *testing.T) {
t.Parallel()
tests := []struct {
s string
output domain.PlayerSnapshotSortBy
expectedErr error
}{
{
s: "id",
output: domain.PlayerSnapshotSortByID,
},
{
s: "date",
output: domain.PlayerSnapshotSortByDate,
},
{
s: "unsupported",
expectedErr: domain.ErrUnsupportedSortBy,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
res, err := domain.NewPlayerSnapshotSortBy(tt.s)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.output, res)
})
}
}

View File

@ -10,49 +10,42 @@ import (
func TestNewSortDirection(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
tests := []struct {
s string
output domain.SortDirection
expectedErr error
}{
{
s: "asc",
output: domain.SortDirectionASC,
},
{
s: "ASC",
output: domain.SortDirectionASC,
},
{
s: "desc",
output: domain.SortDirectionDESC,
},
{
s: "DESC",
output: domain.SortDirectionDESC,
},
{
s: "unsupported",
expectedErr: domain.ErrUnsupportedSortDirection,
},
}
tests := []struct {
s string
output domain.SortDirection
}{
{
s: "asc",
output: domain.SortDirectionASC,
},
{
s: "ASC",
output: domain.SortDirectionASC,
},
{
s: "desc",
output: domain.SortDirectionDESC,
},
{
s: "DESC",
output: domain.SortDirectionDESC,
},
}
for _, tt := range tests {
tt := tt
for _, tt := range tests {
tt := tt
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
res, err := domain.NewSortDirection(tt.s)
assert.NoError(t, err)
assert.Equal(t, tt.output, res)
})
}
})
t.Run("ERR: unsupported sort direction", func(t *testing.T) {
t.Parallel()
res, err := domain.NewSortDirection("unsupported")
assert.ErrorIs(t, err, domain.ErrUnsupportedSortDirection)
assert.Zero(t, res)
})
res, err := domain.NewSortDirection(tt.s)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.output, res)
})
}
}

View File

@ -11,63 +11,56 @@ import (
func TestNewTribeSortBy(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
tests := []struct {
s string
output domain.TribeSortBy
expectedErr error
}{
{
s: "id",
output: domain.TribeSortByID,
},
{
s: "scoreAtt",
output: domain.TribeSortByScoreAtt,
},
{
s: "scoreDef",
output: domain.TribeSortByScoreDef,
},
{
s: "scoreTotal",
output: domain.TribeSortByScoreTotal,
},
{
s: "points",
output: domain.TribeSortByPoints,
},
{
s: "dominance",
output: domain.TribeSortByDominance,
},
{
s: "deletedAt",
output: domain.TribeSortByDeletedAt,
},
{
s: "unsupported",
expectedErr: domain.ErrUnsupportedSortBy,
},
}
tests := []struct {
s string
output domain.TribeSortBy
}{
{
s: "id",
output: domain.TribeSortByID,
},
{
s: "scoreAtt",
output: domain.TribeSortByScoreAtt,
},
{
s: "scoreDef",
output: domain.TribeSortByScoreDef,
},
{
s: "scoreTotal",
output: domain.TribeSortByScoreTotal,
},
{
s: "points",
output: domain.TribeSortByPoints,
},
{
s: "dominance",
output: domain.TribeSortByDominance,
},
{
s: "deletedAt",
output: domain.TribeSortByDeletedAt,
},
}
for _, tt := range tests {
tt := tt
for _, tt := range tests {
tt := tt
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
t.Run(tt.s, func(t *testing.T) {
t.Parallel()
res, err := domain.NewTribeSortBy(tt.s)
assert.NoError(t, err)
assert.Equal(t, tt.output, res)
})
}
})
t.Run("ERR: unsupported sort by", func(t *testing.T) {
t.Parallel()
res, err := domain.NewTribeSortBy("unsupported")
assert.ErrorIs(t, err, domain.ErrUnsupportedSortBy)
assert.Zero(t, res)
})
res, err := domain.NewTribeSortBy(tt.s)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Equal(t, tt.output, res)
})
}
}
func TestTribeNotFoundError(t *testing.T) {

View File

@ -63,8 +63,8 @@ func (e *ennoblement) list(w http.ResponseWriter, r *http.Request) {
}
// @ID listPlayerEnnoblements
// @Summary List the given user's ennoblements
// @Description List the given user's ennoblements
// @Summary List the given player's ennoblements
// @Description List the given player's ennoblements
// @Tags versions,servers,players,ennoblements
// @Produce json
// @Success 200 {object} model.ListEnnoblementsResp

View File

@ -4,6 +4,10 @@ import (
"time"
)
const (
dateFormat = "2006-01-02"
)
type NullTime struct {
Time time.Time
Valid bool

View File

@ -9,6 +9,10 @@ import (
"github.com/stretchr/testify/assert"
)
const (
dateFormat = "2006-01-02"
)
func TestNullTime_MarshalJSON(t *testing.T) {
t.Parallel()

View File

@ -0,0 +1,63 @@
package model
import (
"gitea.dwysokinski.me/twhelp/core/internal/domain"
)
type PlayerSnapshot struct {
ID int64 `json:"id"`
NumVillages int64 `json:"numVillages"`
Points int64 `json:"points"`
Rank int64 `json:"rank"`
Tribe NullTribeMeta `json:"tribe" extensions:"x-nullable"`
RankAtt int64 `json:"rankAtt"`
ScoreAtt int64 `json:"scoreAtt"`
RankDef int64 `json:"rankDef"`
ScoreDef int64 `json:"scoreDef"`
RankSup int64 `json:"rankSup"`
ScoreSup int64 `json:"scoreSup"`
RankTotal int64 `json:"rankTotal"`
ScoreTotal int64 `json:"scoreTotal"`
Date string `json:"date" swaggertype:"string" format:"date"`
} // @name PlayerSnapshot
func NewPlayerSnapshot(s domain.PlayerSnapshotWithRelations) PlayerSnapshot {
var t NullTribeMeta
if s.Tribe.Valid {
t = NullTribeMeta{
Valid: true,
Tribe: NewTribeMeta(s.Tribe.Tribe),
}
}
return PlayerSnapshot{
ID: s.ID,
NumVillages: s.NumVillages,
Points: s.Points,
Rank: s.Rank,
Tribe: t,
RankAtt: s.RankAtt,
ScoreAtt: s.ScoreAtt,
RankDef: s.RankDef,
ScoreDef: s.ScoreDef,
RankSup: s.RankSup,
ScoreSup: s.ScoreSup,
RankTotal: s.RankTotal,
ScoreTotal: s.ScoreTotal,
Date: s.Date.Format(dateFormat),
}
}
type ListPlayerSnapshotsResp struct {
Data []PlayerSnapshot `json:"data"`
} // @name ListPlayerSnapshotsResp
func NewListPlayerSnapshotsResp(snapshots []domain.PlayerSnapshotWithRelations) ListPlayerSnapshotsResp {
resp := ListPlayerSnapshotsResp{
Data: make([]PlayerSnapshot, 0, len(snapshots)),
}
for _, p := range snapshots {
resp.Data = append(resp.Data, NewPlayerSnapshot(p))
}
return resp
}

View File

@ -0,0 +1,137 @@
package model_test
import (
"testing"
"time"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/router/rest/internal/model"
"github.com/stretchr/testify/assert"
)
func TestNewPlayerSnapshot(t *testing.T) {
t.Parallel()
snapshot := domain.PlayerSnapshotWithRelations{
PlayerSnapshot: domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated{
RankAtt: 99,
ScoreAtt: 100,
RankDef: 101,
ScoreDef: 102,
RankSup: 103,
ScoreSup: 104,
RankTotal: 105,
ScoreTotal: 106,
},
ID: 999,
PlayerID: 150,
ServerKey: "pl151",
NumVillages: 100,
Points: 101,
Rank: 102,
TribeID: 15,
Date: time.Now(),
CreatedAt: time.Now().Add(-5 * time.Minute),
},
Tribe: domain.NullTribeMeta{
Valid: true,
Tribe: domain.TribeMeta{
ID: 15,
Name: "Name",
Tag: "Tag",
ProfileURL: "profile-15",
},
},
}
assertPlayerSnapshot(t, snapshot, model.NewPlayerSnapshot(snapshot))
}
func TestNewListPlayerSnapshotsResp(t *testing.T) {
t.Parallel()
snapshots := []domain.PlayerSnapshotWithRelations{
{
PlayerSnapshot: domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated{
RankAtt: 99,
ScoreAtt: 100,
RankDef: 101,
ScoreDef: 102,
RankSup: 103,
ScoreSup: 104,
RankTotal: 105,
ScoreTotal: 106,
},
ID: 999,
PlayerID: 150,
ServerKey: "pl151",
NumVillages: 100,
Points: 101,
Rank: 102,
TribeID: 15,
Date: time.Now(),
CreatedAt: time.Now().Add(-5 * time.Minute),
},
Tribe: domain.NullTribeMeta{
Valid: true,
Tribe: domain.TribeMeta{
ID: 15,
Name: "Name",
Tag: "Tag",
ProfileURL: "profile-15",
},
},
},
{
PlayerSnapshot: domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated{
RankAtt: 999,
ScoreAtt: 1000,
RankDef: 1010,
ScoreDef: 1020,
RankSup: 1030,
ScoreSup: 1040,
RankTotal: 1050,
ScoreTotal: 1060,
},
ID: 9990,
PlayerID: 150,
ServerKey: "pl151",
NumVillages: 1000,
Points: 1010,
Rank: 1020,
TribeID: 0,
Date: time.Now().Add(-24 * time.Hour),
CreatedAt: time.Now().Add(-36 * time.Hour),
},
Tribe: domain.NullTribeMeta{
Valid: false,
},
},
}
resp := model.NewListPlayerSnapshotsResp(snapshots)
assert.Len(t, resp.Data, len(snapshots))
for i, snapshot := range resp.Data {
assertPlayerSnapshot(t, snapshots[i], snapshot)
}
}
func assertPlayerSnapshot(tb testing.TB, dp domain.PlayerSnapshotWithRelations, rp model.PlayerSnapshot) {
tb.Helper()
assert.Equal(tb, dp.ID, rp.ID)
assert.Equal(tb, dp.NumVillages, rp.NumVillages)
assert.Equal(tb, dp.Points, rp.Points)
assert.Equal(tb, dp.Rank, rp.Rank)
assert.Equal(tb, dp.RankAtt, rp.RankAtt)
assert.Equal(tb, dp.ScoreAtt, rp.ScoreAtt)
assert.Equal(tb, dp.RankDef, rp.RankDef)
assert.Equal(tb, dp.ScoreDef, rp.ScoreDef)
assert.Equal(tb, dp.RankSup, rp.RankSup)
assert.Equal(tb, dp.ScoreSup, rp.ScoreSup)
assert.Equal(tb, dp.RankTotal, rp.RankTotal)
assert.Equal(tb, dp.ScoreTotal, rp.ScoreTotal)
assert.Equal(tb, dp.Date.Format(dateFormat), rp.Date)
assertNullTribeMeta(tb, dp.Tribe, rp.Tribe)
}

View File

@ -123,7 +123,7 @@ func verifyPlayerID(svc PlayerService) func(http.Handler) http.Handler {
return
}
if _, err := svc.GetByServerKeyAndID(
if _, err = svc.GetByServerKeyAndID(
ctx,
routeCtx.URLParam("serverKey"),
playerID,

View File

@ -0,0 +1,80 @@
package rest
import (
"context"
"net/http"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/router/rest/internal/model"
"github.com/go-chi/chi/v5"
)
const (
playerSnapshotDefaultOffset = 0
playerSnapshotDefaultLimit = 100
)
//counterfeiter:generate -o internal/mock/player_snapshot_service.gen.go . PlayerSnapshotService
type PlayerSnapshotService interface {
ListCountWithRelations(ctx context.Context, params domain.ListPlayerSnapshotsParams) ([]domain.PlayerSnapshotWithRelations, int64, error)
}
type playerSnapshot struct {
svc PlayerSnapshotService
}
// @ID listPlayerSnapshots
// @Summary List the given player's snapshots
// @Description List the given player's snapshots
// @Tags versions,servers,players,snapshots
// @Produce json
// @Success 200 {object} model.ListPlayerSnapshotsResp
// @Success 400 {object} model.ErrorResp
// @Success 404 {object} model.ErrorResp
// @Failure 500 {object} model.ErrorResp
// @Header 200 {integer} X-Total-Count "Total number of records"
// @Param versionCode path string true "Version code"
// @Param serverKey path string true "Server key"
// @Param playerId path integer true "Player ID"
// @Param offset query int false "specifies where to start a page" minimum(0) default(0)
// @Param limit query int false "page size" minimum(1) maximum(100) default(100)
// @Param sort query []string false "format: field:direction, default: [date:asc,id:asc]" Enums(id:asc,id:desc,date:asc,date:desc)
// @Router /versions/{versionCode}/servers/{serverKey}/players/{playerId}/history [get]
func (p *playerSnapshot) list(w http.ResponseWriter, r *http.Request) {
var err error
ctx := r.Context()
routeCtx := chi.RouteContext(ctx)
params := domain.ListPlayerSnapshotsParams{
ServerKeys: []string{routeCtx.URLParam("serverKey")},
}
query := queryParams{r.URL.Query()}
playerID, err := urlParamInt64(routeCtx, "playerId")
if err != nil {
renderErr(w, err)
return
}
params.PlayerIDs = []int64{playerID}
params.Pagination, err = query.pagination(playerSnapshotDefaultOffset, playerSnapshotDefaultLimit)
if err != nil {
renderErr(w, err)
return
}
params.Sort, err = query.playerSnapshotSort()
if err != nil {
renderErr(w, err)
return
}
snapshots, count, err := p.svc.ListCountWithRelations(ctx, params)
if err != nil {
renderErr(w, err)
return
}
setTotalCountHeader(w.Header(), count)
renderJSON(w, http.StatusOK, model.NewListPlayerSnapshotsResp(snapshots))
}

View File

@ -0,0 +1,567 @@
package rest_test
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/router/rest"
"gitea.dwysokinski.me/twhelp/core/internal/router/rest/internal/mock"
"gitea.dwysokinski.me/twhelp/core/internal/router/rest/internal/model"
"github.com/google/go-cmp/cmp"
)
func TestPlayerSnapshot_list(t *testing.T) {
t.Parallel()
now := time.Now()
version := domain.Version{
Code: "pl",
Name: "Poland",
Host: "plemiona.pl",
Timezone: "Europe/Warsaw",
}
server := domain.Server{
Key: "pl151",
URL: "https://pl151.plemiona.pl",
Open: true,
Special: false,
VersionCode: "pl",
}
player := domain.Player{
ID: 124,
Name: "player 124",
ProfileURL: "player-124",
}
playerID := strconv.FormatInt(player.ID, 10)
tests := []struct {
name string
setup func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
)
versionCode string
key string
playerID string
queryParams url.Values
expectedStatus int
target any
expectedResponse any
expectedTotalCount string
}{
{
name: "OK: without params",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
playerSnapshotSvc.ListCountWithRelationsCalls(func(
_ context.Context,
params domain.ListPlayerSnapshotsParams,
) ([]domain.PlayerSnapshotWithRelations, int64, error) {
expectedParams := domain.ListPlayerSnapshotsParams{
ServerKeys: []string{server.Key},
PlayerIDs: []int64{player.ID},
Pagination: domain.Pagination{
Limit: 100,
Offset: 0,
},
}
if diff := cmp.Diff(params, expectedParams); diff != "" {
return nil, 0, fmt.Errorf("validation failed: %s", diff)
}
snapshots := []domain.PlayerSnapshotWithRelations{
{
PlayerSnapshot: domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated{
RankAtt: 99,
ScoreAtt: 100,
RankDef: 101,
ScoreDef: 102,
RankSup: 103,
ScoreSup: 104,
RankTotal: 105,
ScoreTotal: 106,
},
ID: 999,
PlayerID: player.ID,
ServerKey: server.Key,
NumVillages: 100,
Points: 101,
Rank: 102,
TribeID: 15,
Date: now,
CreatedAt: now.Add(-5 * time.Minute),
},
Tribe: domain.NullTribeMeta{
Valid: true,
Tribe: domain.TribeMeta{
ID: 15,
Name: "Name",
Tag: "Tag",
ProfileURL: "profile-15",
},
},
},
{
PlayerSnapshot: domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated{
RankAtt: 999,
ScoreAtt: 1000,
RankDef: 1010,
ScoreDef: 1020,
RankSup: 1030,
ScoreSup: 1040,
RankTotal: 1050,
ScoreTotal: 1060,
},
ID: 9990,
PlayerID: player.ID,
ServerKey: server.Key,
NumVillages: 1000,
Points: 1010,
Rank: 1020,
TribeID: 0,
Date: now.Add(-24 * time.Hour),
CreatedAt: now.Add(-36 * time.Hour),
},
Tribe: domain.NullTribeMeta{
Valid: false,
},
},
}
return snapshots, int64(len(snapshots)), nil
})
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: nil,
expectedStatus: http.StatusOK,
target: &model.ListPlayerSnapshotsResp{},
expectedResponse: &model.ListPlayerSnapshotsResp{
Data: []model.PlayerSnapshot{
{
ID: 999,
NumVillages: 100,
Points: 101,
Rank: 102,
RankAtt: 99,
ScoreAtt: 100,
RankDef: 101,
ScoreDef: 102,
RankSup: 103,
ScoreSup: 104,
RankTotal: 105,
ScoreTotal: 106,
Tribe: model.NullTribeMeta{
Tribe: model.TribeMeta{
ID: 15,
Name: "Name",
Tag: "Tag",
ProfileURL: "profile-15",
},
Valid: true,
},
Date: now.Format(dateFormat),
},
{
ID: 9990,
NumVillages: 1000,
Points: 1010,
Rank: 1020,
RankAtt: 999,
ScoreAtt: 1000,
RankDef: 1010,
ScoreDef: 1020,
RankSup: 1030,
ScoreSup: 1040,
RankTotal: 1050,
ScoreTotal: 1060,
Date: now.Add(-24 * time.Hour).Format(dateFormat),
},
},
},
expectedTotalCount: "2",
},
{
name: "OK: limit=1,offset=100,sort=[date:desc,id:desc]",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
playerSnapshotSvc.ListCountWithRelationsCalls(func(
_ context.Context,
params domain.ListPlayerSnapshotsParams,
) ([]domain.PlayerSnapshotWithRelations, int64, error) {
expectedParams := domain.ListPlayerSnapshotsParams{
ServerKeys: []string{server.Key},
PlayerIDs: []int64{player.ID},
Pagination: domain.Pagination{
Limit: 1,
Offset: 100,
},
Sort: []domain.PlayerSnapshotSort{
{By: domain.PlayerSnapshotSortByDate, Direction: domain.SortDirectionDESC},
{By: domain.PlayerSnapshotSortByID, Direction: domain.SortDirectionDESC},
},
}
if diff := cmp.Diff(params, expectedParams); diff != "" {
fmt.Println(diff)
return nil, 0, fmt.Errorf("validation failed: %s", diff)
}
snapshots := []domain.PlayerSnapshotWithRelations{
{
PlayerSnapshot: domain.PlayerSnapshot{
OpponentsDefeated: domain.OpponentsDefeated{
RankAtt: 999,
ScoreAtt: 1000,
RankDef: 1010,
ScoreDef: 1020,
RankSup: 1030,
ScoreSup: 1040,
RankTotal: 1050,
ScoreTotal: 1060,
},
ID: 9990,
PlayerID: player.ID,
ServerKey: server.Key,
NumVillages: 1000,
Points: 1010,
Rank: 1020,
TribeID: 0,
Date: now.Add(-24 * time.Hour),
CreatedAt: now.Add(-36 * time.Hour),
},
Tribe: domain.NullTribeMeta{
Valid: false,
},
},
}
return snapshots, int64(len(snapshots)) + int64(params.Pagination.Offset), nil
})
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: url.Values{
"limit": []string{"1"},
"offset": []string{"100"},
"sort": []string{"date:desc", "id:desc"},
},
expectedStatus: http.StatusOK,
target: &model.ListPlayerSnapshotsResp{},
expectedResponse: &model.ListPlayerSnapshotsResp{
Data: []model.PlayerSnapshot{
{
ID: 9990,
NumVillages: 1000,
Points: 1010,
Rank: 1020,
RankAtt: 999,
ScoreAtt: 1000,
RankDef: 1010,
ScoreDef: 1020,
RankSup: 1030,
ScoreSup: 1040,
RankTotal: 1050,
ScoreTotal: 1060,
Date: now.Add(-24 * time.Hour).Format(dateFormat),
},
},
},
expectedTotalCount: "101",
},
{
name: "ERR: limit is not a valid int32",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: url.Values{
"limit": []string{"asd"},
},
expectedStatus: http.StatusBadRequest,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeValidationError.String(),
Message: "limit: strconv.ParseInt: parsing \"asd\": invalid syntax",
},
},
expectedTotalCount: "",
},
{
name: "ERR: offset is not a valid int32",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: url.Values{
"offset": []string{"asd"},
},
expectedStatus: http.StatusBadRequest,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeValidationError.String(),
Message: "offset: strconv.ParseInt: parsing \"asd\": invalid syntax",
},
},
expectedTotalCount: "",
},
{
name: "ERR: sort - invalid format",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: url.Values{
"sort": []string{"createdAt:asc", "test"},
},
expectedStatus: http.StatusBadRequest,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeValidationError.String(),
Message: "sort[1]: parsing \"test\": invalid syntax, expected field:direction",
},
},
expectedTotalCount: "",
},
{
name: "ERR: sort - unsupported field",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: url.Values{
"sort": []string{"date:asc", "test:asc"},
},
expectedStatus: http.StatusBadRequest,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeValidationError.String(),
Message: "sort[1]: unsupported sort by: \"test\"",
},
},
expectedTotalCount: "",
},
{
name: "ERR: sort - unsupported direction",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(player, nil)
},
versionCode: version.Code,
key: server.Key,
playerID: playerID,
queryParams: url.Values{
"sort": []string{"date:asc2"},
},
expectedStatus: http.StatusBadRequest,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeValidationError.String(),
Message: "sort[0]: unsupported sort direction: \"asc2\"",
},
},
expectedTotalCount: "",
},
{
name: "ERR: player id is not a valid int64",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
},
versionCode: version.Code,
key: server.Key,
playerID: "asdf",
expectedStatus: http.StatusBadRequest,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeValidationError.String(),
Message: "playerId: strconv.ParseInt: parsing \"asdf\": invalid syntax",
},
},
expectedTotalCount: "",
},
{
name: "ERR: version not found",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(domain.Version{}, domain.VersionNotFoundError{VerCode: version.Code + "2"})
},
versionCode: version.Code + "2",
key: server.Key,
playerID: playerID,
expectedStatus: http.StatusNotFound,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeEntityNotFound.String(),
Message: fmt.Sprintf("version (code=%s) not found", version.Code+"2"),
},
},
expectedTotalCount: "",
},
{
name: "ERR: server not found",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(domain.Server{}, domain.ServerNotFoundError{Key: server.Key + "2"})
},
versionCode: version.Code,
key: server.Key + "2",
playerID: playerID,
expectedStatus: http.StatusNotFound,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeEntityNotFound.String(),
Message: fmt.Sprintf("server (key=%s) not found", server.Key+"2"),
},
},
expectedTotalCount: "",
},
{
name: "ERR: player not found",
setup: func(
versionSvc *mock.FakeVersionService,
serverSvc *mock.FakeServerService,
playerSvc *mock.FakePlayerService,
playerSnapshotSvc *mock.FakePlayerSnapshotService,
) {
versionSvc.GetByCodeReturns(version, nil)
serverSvc.GetNormalByVersionCodeAndKeyReturns(server, nil)
playerSvc.GetByServerKeyAndIDReturns(domain.Player{}, domain.PlayerNotFoundError{ID: 1241})
},
versionCode: version.Code,
key: server.Key,
playerID: playerID + "1",
expectedStatus: http.StatusNotFound,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeEntityNotFound.String(),
Message: fmt.Sprintf("player (id=%d) not found", 1241),
},
},
expectedTotalCount: "",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
versionSvc := &mock.FakeVersionService{}
serverSvc := &mock.FakeServerService{}
playerSnapshotSvc := &mock.FakePlayerSnapshotService{}
playerSvc := &mock.FakePlayerService{}
tt.setup(versionSvc, serverSvc, playerSvc, playerSnapshotSvc)
router := rest.NewRouter(rest.RouterConfig{
VersionService: versionSvc,
ServerService: serverSvc,
PlayerSnapshotService: playerSnapshotSvc,
PlayerService: playerSvc,
})
target := fmt.Sprintf(
"/v1/versions/%s/servers/%s/players/%s/history?%s",
tt.versionCode,
tt.key,
tt.playerID,
tt.queryParams.Encode(),
)
resp := doRequest(router, http.MethodGet, target, nil)
defer resp.Body.Close()
assertTotalCount(t, resp.Header, tt.expectedTotalCount)
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
})
}
}

View File

@ -82,7 +82,7 @@ func (q queryParams) tribeSort() ([]domain.TribeSort, error) {
if err != nil {
return nil, err
}
if ss == nil {
if len(ss) == 0 {
return nil, nil
}
@ -118,7 +118,7 @@ func (q queryParams) playerSort() ([]domain.PlayerSort, error) {
if err != nil {
return nil, err
}
if ss == nil {
if len(ss) == 0 {
return nil, nil
}
@ -149,12 +149,48 @@ func (q queryParams) playerSort() ([]domain.PlayerSort, error) {
return res, nil
}
func (q queryParams) playerSnapshotSort() ([]domain.PlayerSnapshotSort, error) {
ss, err := q.sort()
if err != nil {
return nil, err
}
if len(ss) == 0 {
return nil, nil
}
res := make([]domain.PlayerSnapshotSort, 0, len(ss))
for i, s := range ss {
by, err := domain.NewPlayerSnapshotSortBy(s[0])
if err != nil {
return nil, domain.ValidationError{
Field: fmt.Sprintf("sort[%d]", i),
Err: err,
}
}
direction, err := domain.NewSortDirection(s[1])
if err != nil {
return nil, domain.ValidationError{
Field: fmt.Sprintf("sort[%d]", i),
Err: err,
}
}
res = append(res, domain.PlayerSnapshotSort{
By: by,
Direction: direction,
})
}
return res, nil
}
func (q queryParams) ennoblementSort() ([]domain.EnnoblementSort, error) {
ss, err := q.sort()
if err != nil {
return nil, err
}
if ss == nil {
if len(ss) == 0 {
return nil, nil
}
@ -186,8 +222,8 @@ func (q queryParams) ennoblementSort() ([]domain.EnnoblementSort, error) {
}
func (q queryParams) sort() ([][2]string, error) {
ss, ok := q.values["sort"]
if !ok || len(ss) == 0 {
ss := q.values["sort"]
if len(ss) == 0 {
return nil, nil
}

View File

@ -34,14 +34,15 @@ type SwaggerConfig struct {
}
type RouterConfig struct {
VersionService VersionService
ServerService ServerService
TribeService TribeService
PlayerService PlayerService
VillageService VillageService
EnnoblementService EnnoblementService
CORS CORSConfig
Swagger SwaggerConfig
VersionService VersionService
ServerService ServerService
TribeService TribeService
PlayerService PlayerService
VillageService VillageService
EnnoblementService EnnoblementService
PlayerSnapshotService PlayerSnapshotService
CORS CORSConfig
Swagger SwaggerConfig
}
func NewRouter(cfg RouterConfig) *chi.Mux {
@ -58,6 +59,9 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
playerHandler := &player{
svc: cfg.PlayerService,
}
playerSnapshotHandler := &playerSnapshot{
svc: cfg.PlayerSnapshotService,
}
villageHandler := &village{
svc: cfg.VillageService,
}
@ -114,6 +118,7 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
verifyPlayerIDMiddleware := verifyPlayerID(cfg.PlayerService)
r.With(verifyPlayerIDMiddleware).Get("/ennoblements", ennoblementHandler.listPlayer)
r.With(verifyPlayerIDMiddleware).Get("/history", playerSnapshotHandler.list)
})
})
r.With(verifyServerKeyMiddleware).

View File

@ -19,6 +19,10 @@ import (
"github.com/stretchr/testify/assert"
)
const (
dateFormat = "2006-01-02"
)
func TestRouteNotFound(t *testing.T) {
t.Parallel()

View File

@ -367,7 +367,7 @@ func (l listPlayersParamsBuilder) build() (domain.ListPlayersParams, error) {
}
if l.Pagination.Limit == 0 {
l.Pagination.Limit = tribeMaxLimit
l.Pagination.Limit = playerMaxLimit
}
if len(l.Sort) > playerSortMaxLen {

View File

@ -8,6 +8,11 @@ import (
"gitea.dwysokinski.me/twhelp/core/internal/domain"
)
const (
playerSnapshotMaxLimit = 100
playerSnapshotSortMaxLen = 2
)
//counterfeiter:generate -o internal/mock/player_lister.gen.go . PlayerLister
type PlayerLister interface {
List(ctx context.Context, params domain.ListPlayersParams) ([]domain.Player, error)
@ -16,6 +21,7 @@ type PlayerLister interface {
//counterfeiter:generate -o internal/mock/player_snapshot_repository.gen.go . PlayerSnapshotRepository
type PlayerSnapshotRepository interface {
Create(ctx context.Context, params ...domain.CreatePlayerSnapshotParams) error
ListCountWithRelations(ctx context.Context, params domain.ListPlayerSnapshotsParams) ([]domain.PlayerSnapshotWithRelations, int64, error)
Delete(ctx context.Context, serverKey string, createdAtLTE time.Time) error
}
@ -80,6 +86,48 @@ func (p *PlayerSnapshot) Create(ctx context.Context, key string, date time.Time)
return nil
}
func (p *PlayerSnapshot) ListCountWithRelations(
ctx context.Context,
params domain.ListPlayerSnapshotsParams,
) ([]domain.PlayerSnapshotWithRelations, int64, error) {
if len(params.Sort) == 0 {
params.Sort = []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
}
}
if params.Pagination.Limit == 0 {
params.Pagination.Limit = playerSnapshotMaxLimit
}
if len(params.Sort) > playerSnapshotSortMaxLen {
return nil, 0, domain.ValidationError{
Field: "sort",
Err: domain.MaxLengthError{
Max: playerSnapshotSortMaxLen,
},
}
}
if err := validatePagination(params.Pagination, playerSnapshotMaxLimit); err != nil {
return nil, 0, fmt.Errorf("validatePagination: %w", err)
}
snapshots, count, err := p.repo.ListCountWithRelations(ctx, params)
if err != nil {
return nil, 0, fmt.Errorf("PlayerSnapshotRepository.ListCountWithRelations: %w", err)
}
return snapshots, count, nil
}
func (p *PlayerSnapshot) CleanUp(ctx context.Context, srv domain.Server) error {
if srv.Special || srv.Open || srv.PlayerSnapshotsCreatedAt.After(time.Now().Add(-30*24*time.Hour) /* 30 days */) {
return nil

View File

@ -2,12 +2,14 @@ package service_test
import (
"context"
"fmt"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/service"
"gitea.dwysokinski.me/twhelp/core/internal/service/internal/mock"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -83,6 +85,193 @@ func TestPlayerSnapshot_Create(t *testing.T) {
}
}
func TestPlayerSnapshot_ListCountWithRelations(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
limit int32
sort []domain.PlayerSnapshotSort
}{
{
name: "default limit, default sort",
limit: 0,
sort: nil,
},
{
name: "custom limit",
limit: 99,
},
{
name: "custom sort",
limit: 0,
sort: []domain.PlayerSnapshotSort{
{By: domain.PlayerSnapshotSortByDate, Direction: domain.SortDirectionDESC},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
limit := tt.limit
if limit == 0 {
limit = 100
}
repo := &mock.FakePlayerSnapshotRepository{}
repo.ListCountWithRelationsCalls(func(
_ context.Context,
params domain.ListPlayerSnapshotsParams,
) ([]domain.PlayerSnapshotWithRelations, int64, error) {
expectedParams := domain.ListPlayerSnapshotsParams{
Pagination: domain.Pagination{
Limit: limit,
},
Sort: func(sort []domain.PlayerSnapshotSort) []domain.PlayerSnapshotSort {
if len(sort) == 0 {
return []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
}
}
return sort
}(tt.sort),
}
if diff := cmp.Diff(params, expectedParams); diff != "" {
return nil, 0, fmt.Errorf("validation failed: %s", diff)
}
return make([]domain.PlayerSnapshotWithRelations, params.Pagination.Limit), int64(params.Pagination.Limit), nil
})
svc := service.NewPlayerSnapshot(repo, &mock.FakePlayerLister{})
params := domain.ListPlayerSnapshotsParams{
Pagination: domain.Pagination{
Limit: tt.limit,
},
Sort: tt.sort,
}
snapshots, count, err := svc.ListCountWithRelations(context.Background(), params)
assert.NoError(t, err)
assert.EqualValues(t, limit, count)
assert.Len(t, snapshots, int(limit))
})
}
})
t.Run("ERR: validation failed", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
params domain.ListPlayerSnapshotsParams
expectedErr error
}{
{
name: "params.Pagination.Limit < 0",
params: domain.ListPlayerSnapshotsParams{
Pagination: domain.Pagination{
Limit: -1,
},
},
expectedErr: domain.ValidationError{
Field: "limit",
Err: domain.MinError{
Min: 1,
},
},
},
{
name: "params.Pagination.Limit > 100",
params: domain.ListPlayerSnapshotsParams{
Pagination: domain.Pagination{
Limit: 101,
},
},
expectedErr: domain.ValidationError{
Field: "limit",
Err: domain.MaxError{
Max: 100,
},
},
},
{
name: "params.Pagination.Offset < 0",
params: domain.ListPlayerSnapshotsParams{
Pagination: domain.Pagination{
Offset: -1,
},
},
expectedErr: domain.ValidationError{
Field: "offset",
Err: domain.MinError{
Min: 0,
},
},
},
{
name: "len(params.Sort) > 2",
params: domain.ListPlayerSnapshotsParams{
Sort: []domain.PlayerSnapshotSort{
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByDate,
Direction: domain.SortDirectionASC,
},
{
By: domain.PlayerSnapshotSortByID,
Direction: domain.SortDirectionDESC,
},
},
},
expectedErr: domain.ValidationError{
Field: "sort",
Err: domain.MaxLengthError{
Max: 2,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
repo := &mock.FakePlayerSnapshotRepository{}
svc := service.NewPlayerSnapshot(repo, &mock.FakePlayerLister{})
snapshots, count, err := svc.ListCountWithRelations(context.Background(), tt.params)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Zero(t, snapshots)
assert.Zero(t, count)
assert.Equal(t, 0, repo.ListCountWithRelationsCallCount())
})
}
})
}
func TestPlayerSnapshot_CleanUp(t *testing.T) {
t.Parallel()