feat: new endpoint GET /api/v2/versions/{versionCode}/servers/{serverKey}/snapshots #52

Merged
Kichiyaki merged 1 commits from feat/new-endpoint-list-server-snapshots into master 2024-05-13 05:12:06 +00:00
19 changed files with 1090 additions and 15 deletions

View File

@ -471,6 +471,25 @@ paths:
$ref: "#/components/responses/ListTribeChangesResponse"
default:
$ref: "#/components/responses/ErrorResponse"
/v2/versions/{versionCode}/servers/{serverKey}/snapshots:
get:
operationId: listServerServerSnapshots
tags:
- versions
- servers
- snapshots
description: List the given server's snapshots
parameters:
- $ref: "#/components/parameters/VersionCodePathParam"
- $ref: "#/components/parameters/ServerKeyPathParam"
- $ref: "#/components/parameters/CursorQueryParam"
- $ref: "#/components/parameters/LimitQueryParam"
- $ref: "#/components/parameters/ServerSnapshotSortQueryParam"
responses:
200:
$ref: "#/components/responses/ListServerSnapshotsResponse"
default:
$ref: "#/components/responses/ErrorResponse"
/v2/versions/{versionCode}/servers/{serverKey}/tribes/{tribeId}/snapshots:
get:
operationId: listTribeTribeSnapshots
@ -1594,6 +1613,52 @@ components:
createdAt:
type: string
format: date-time
ServerSnapshot:
type: object
required:
- id
- server
- numPlayers
- numActivePlayers
- numInactivePlayers
- numTribes
- numActiveTribes
- numInactiveTribes
- numVillages
- numBarbarianVillages
- numBonusVillages
- numPlayerVillages
- date
properties:
id:
$ref: "#/components/schemas/IntId"
server:
$ref: "#/components/schemas/ServerMeta"
numPlayers:
type: integer
description: numActivePlayers+numInactivePlayers
numActivePlayers:
type: integer
numInactivePlayers:
type: integer
numTribes:
type: integer
description: numActiveTribes+numInactiveTribes
numActiveTribes:
type: integer
numInactiveTribes:
type: integer
numVillages:
type: integer
numBarbarianVillages:
type: integer
numBonusVillages:
type: integer
numPlayerVillages:
type: integer
date:
type: string
format: date
TribeSnapshot:
type: object
required:
@ -1675,7 +1740,7 @@ components:
x-go-type-skip-optional-pointer: true
allOf:
- $ref: "#/components/schemas/CursorString"
PaginationResponse:
Pagination:
type: object
properties:
cursor:
@ -1840,6 +1905,20 @@ components:
- date:ASC
- date:DESC
maxItems: 1
ServerSnapshotSortQueryParam:
name: sort
in: query
description: Order matters!
schema:
type: array
default:
- date:ASC
items:
type: string
enum:
- date:ASC
- date:DESC
maxItems: 1
PlayerSnapshotSortQueryParam:
name: sort
in: query
@ -1905,7 +1984,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -1931,7 +2010,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -1990,7 +2069,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2016,7 +2095,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2031,7 +2110,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2057,7 +2136,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2083,7 +2162,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2098,7 +2177,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2107,13 +2186,28 @@ components:
type: array
items:
$ref: "#/components/schemas/TribeChange"
ListServerSnapshotsResponse:
description: ""
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
properties:
data:
type: array
items:
$ref: "#/components/schemas/ServerSnapshot"
ListTribeSnapshotsResponse:
description: ""
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data
@ -2128,7 +2222,7 @@ components:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- $ref: "#/components/schemas/Pagination"
- type: object
required:
- data

View File

@ -113,6 +113,7 @@ var cmdServe = &cli.Command{
villageRepo := adapter.NewVillageBunRepository(bunDB)
ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB)
tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB)
serverSnapshotRepo := adapter.NewServerSnapshotBunRepository(bunDB)
tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(bunDB)
playerSnapshotRepo := adapter.NewPlayerSnapshotBunRepository(bunDB)
@ -124,6 +125,7 @@ var cmdServe = &cli.Command{
playerSvc := app.NewPlayerService(playerRepo, tribeChangeSvc, nil, nil)
villageSvc := app.NewVillageService(villageRepo, nil, nil)
ennoblementSvc := app.NewEnnoblementService(ennoblementRepo, nil, nil)
serverSnapshotSvc := app.NewServerSnapshotService(serverSnapshotRepo, serverSvc, nil)
tribeSnapshotSvc := app.NewTribeSnapshotService(tribeSnapshotRepo, tribeSvc, nil)
playerSnapshotSvc := app.NewPlayerSnapshotService(playerSnapshotRepo, playerSvc, nil)
@ -160,6 +162,7 @@ var cmdServe = &cli.Command{
villageSvc,
ennoblementSvc,
tribeChangeSvc,
serverSnapshotSvc,
tribeSnapshotSvc,
playerSnapshotSvc,
port.WithOpenAPIConfig(oapiCfg),

View File

@ -81,6 +81,33 @@ func (repo *ServerSnapshotBunRepository) List(
return domain.NewListServerSnapshotsResult(separateListResultAndNext(converted, params.Limit()))
}
func (repo *ServerSnapshotBunRepository) ListWithRelations(
ctx context.Context,
params domain.ListServerSnapshotsParams,
) (domain.ListServerSnapshotsWithRelationsResult, error) {
var serverSnapshots bunmodel.ServerSnapshots
if err := repo.db.NewSelect().
Model(&serverSnapshots).
Apply(listServerSnapshotsParamsApplier{params: params}.apply).
Relation("Server", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Column(bunmodel.ServerMetaColumns...)
}).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return domain.ListServerSnapshotsWithRelationsResult{}, fmt.Errorf(
"couldn't select server snapshots from the db: %w",
err,
)
}
converted, err := serverSnapshots.ToDomainWithRelations()
if err != nil {
return domain.ListServerSnapshotsWithRelationsResult{}, err
}
return domain.NewListServerSnapshotsWithRelationsResult(separateListResultAndNext(converted, params.Limit()))
}
func (repo *ServerSnapshotBunRepository) Delete(ctx context.Context, serverKey string, dateLTE time.Time) error {
if _, err := repo.db.NewDelete().
Model((*bunmodel.ServerSnapshot)(nil)).

View File

@ -115,7 +115,7 @@ func testServerSnapshotRepository(t *testing.T, newRepos func(t *testing.T) repo
})
})
t.Run("List", func(t *testing.T) {
t.Run("List & ListWithRelations", func(t *testing.T) {
t.Parallel()
repos := newRepos(t)
@ -405,6 +405,14 @@ func testServerSnapshotRepository(t *testing.T, newRepos func(t *testing.T) repo
res, err := repos.serverSnapshot.List(ctx, params)
assertError(t, err)
tt.assertResult(t, params, res)
resWithRelations, err := repos.serverSnapshot.ListWithRelations(ctx, params)
assertError(t, err)
require.Len(t, resWithRelations.ServerSnapshots(), len(res.ServerSnapshots()))
for i, ss := range resWithRelations.ServerSnapshots() {
assert.Equal(t, res.ServerSnapshots()[i], ss.ServerSnapshot())
assert.Equal(t, ss.ServerSnapshot().ServerKey(), ss.Server().Key())
}
})
}
})

View File

@ -70,6 +70,10 @@ type tribeChangeRepository interface {
type serverSnapshotRepository interface {
Create(ctx context.Context, params ...domain.CreateServerSnapshotParams) error
List(ctx context.Context, params domain.ListServerSnapshotsParams) (domain.ListServerSnapshotsResult, error)
ListWithRelations(
ctx context.Context,
params domain.ListServerSnapshotsParams,
) (domain.ListServerSnapshotsWithRelationsResult, error)
Delete(ctx context.Context, serverKey string, dateLTE time.Time) error
}

View File

@ -11,6 +11,10 @@ type ServerSnapshotRepository interface {
// Create persists tribe snapshots in a store (e.g. Postgres).
// Duplicates are ignored.
Create(ctx context.Context, params ...domain.CreateServerSnapshotParams) error
ListWithRelations(
ctx context.Context,
params domain.ListServerSnapshotsParams,
) (domain.ListServerSnapshotsWithRelationsResult, error)
}
type ServerSnapshotService struct {
@ -27,7 +31,6 @@ func NewServerSnapshotService(
return &ServerSnapshotService{repo: repo, serverSvc: serverSvc, pub: pub}
}
//nolint:gocyclo
func (svc *ServerSnapshotService) Create(
ctx context.Context,
createSnapshotsCmdPayload domain.CreateSnapshotsCmdPayload,
@ -60,3 +63,10 @@ func (svc *ServerSnapshotService) Create(
return nil
}
func (svc *ServerSnapshotService) ListWithRelations(
ctx context.Context,
params domain.ListServerSnapshotsParams,
) (domain.ListServerSnapshotsWithRelationsResult, error) {
return svc.repo.ListWithRelations(ctx, params)
}

View File

@ -13,6 +13,7 @@ type ServerSnapshot struct {
ID int `bun:"id,pk,autoincrement,identity"`
ServerKey string `bun:"server_key,nullzero"`
Server Server `bun:"server,rel:belongs-to,join:server_key=key"`
NumPlayers int `bun:"num_players"`
NumActivePlayers int `bun:"num_active_players"`
NumInactivePlayers int `bun:"num_inactive_players"`
@ -55,8 +56,26 @@ func (ss ServerSnapshot) ToDomain() (domain.ServerSnapshot, error) {
return converted, nil
}
func (ss ServerSnapshot) ToDomainWithRelations() (domain.ServerSnapshotWithRelations, error) {
converted, err := ss.ToDomain()
if err != nil {
return domain.ServerSnapshotWithRelations{}, err
}
server, err := ss.Server.ToMeta()
if err != nil {
return domain.ServerSnapshotWithRelations{}, err
}
return converted.WithRelations(server), nil
}
type ServerSnapshots []ServerSnapshot
func (sss ServerSnapshots) ToDomain() (domain.ServerSnapshots, error) {
return sliceToDomain(sss)
}
func (sss ServerSnapshots) ToDomainWithRelations() (domain.ServerSnapshotsWithRelations, error) {
return sliceToDomainWithRelations(sss)
}

View File

@ -100,3 +100,38 @@ func NewServerSnapshot(tb TestingTB, opts ...func(cfg *ServerSnapshotConfig)) do
return ss
}
type ServerSnapshotWithRelationsConfig struct {
ServerSnapshotOptions []func(cfg *ServerSnapshotConfig)
ServerOptions []func(cfg *ServerConfig)
}
func NewServerSnapshotWithRelations(
tb TestingTB,
opts ...func(cfg *ServerSnapshotWithRelationsConfig),
) domain.ServerSnapshotWithRelations {
tb.Helper()
cfg := &ServerSnapshotWithRelationsConfig{}
for _, opt := range opts {
opt(cfg)
}
ss := NewServerSnapshot(tb, cfg.ServerSnapshotOptions...)
if ss.ServerKey() != "" {
cfg.ServerOptions = append([]func(cfg *ServerConfig){
func(cfg *ServerConfig) {
cfg.Key = ss.ServerKey()
},
}, cfg.ServerOptions...)
}
var tribe domain.ServerMeta
if len(cfg.ServerOptions) > 0 {
tribe = NewServer(tb, cfg.ServerOptions...).Meta()
}
return ss.WithRelations(tribe)
}

View File

@ -135,6 +135,13 @@ func (ss ServerSnapshot) CreatedAt() time.Time {
return ss.createdAt
}
func (ss ServerSnapshot) WithRelations(server ServerMeta) ServerSnapshotWithRelations {
return ServerSnapshotWithRelations{
snapshot: ss,
server: server,
}
}
func (ss ServerSnapshot) ToCursor() (ServerSnapshotCursor, error) {
return NewServerSnapshotCursor(ss.id, ss.serverKey, ss.date)
}
@ -145,6 +152,25 @@ func (ss ServerSnapshot) IsZero() bool {
type ServerSnapshots []ServerSnapshot
type ServerSnapshotWithRelations struct {
snapshot ServerSnapshot
server ServerMeta
}
func (ts ServerSnapshotWithRelations) ServerSnapshot() ServerSnapshot {
return ts.snapshot
}
func (ts ServerSnapshotWithRelations) Server() ServerMeta {
return ts.server
}
func (ts ServerSnapshotWithRelations) IsZero() bool {
return ts.snapshot.IsZero()
}
type ServerSnapshotsWithRelations []ServerSnapshotWithRelations
type CreateServerSnapshotParams struct {
serverKey string
numPlayers int
@ -576,3 +602,57 @@ func (res ListServerSnapshotsResult) Self() ServerSnapshotCursor {
func (res ListServerSnapshotsResult) Next() ServerSnapshotCursor {
return res.next
}
type ListServerSnapshotsWithRelationsResult struct {
snapshots ServerSnapshotsWithRelations
self ServerSnapshotCursor
next ServerSnapshotCursor
}
const listServerSnapshotsWithRelationsResultModelName = "ListServerSnapshotsWithRelationsResult"
func NewListServerSnapshotsWithRelationsResult(
snapshots ServerSnapshotsWithRelations,
next ServerSnapshotWithRelations,
) (ListServerSnapshotsWithRelationsResult, error) {
var err error
res := ListServerSnapshotsWithRelationsResult{
snapshots: snapshots,
}
if len(snapshots) > 0 {
res.self, err = snapshots[0].ServerSnapshot().ToCursor()
if err != nil {
return ListServerSnapshotsWithRelationsResult{}, ValidationError{
Model: listServerSnapshotsWithRelationsResultModelName,
Field: "self",
Err: err,
}
}
}
if !next.IsZero() {
res.next, err = next.ServerSnapshot().ToCursor()
if err != nil {
return ListServerSnapshotsWithRelationsResult{}, ValidationError{
Model: listServerSnapshotsWithRelationsResultModelName,
Field: "next",
Err: err,
}
}
}
return res, nil
}
func (res ListServerSnapshotsWithRelationsResult) ServerSnapshots() ServerSnapshotsWithRelations {
return res.snapshots
}
func (res ListServerSnapshotsWithRelationsResult) Self() ServerSnapshotCursor {
return res.self
}
func (res ListServerSnapshotsWithRelationsResult) Next() ServerSnapshotCursor {
return res.next
}

View File

@ -852,3 +852,50 @@ func TestNewListServerSnapshotsResult(t *testing.T) {
assert.True(t, res.Next().IsZero())
})
}
func TestNewListServerSnapshotsWithRelationsResult(t *testing.T) {
t.Parallel()
snapshots := domain.ServerSnapshotsWithRelations{
domaintest.NewServerSnapshotWithRelations(t),
domaintest.NewServerSnapshotWithRelations(t),
domaintest.NewServerSnapshotWithRelations(t),
}
next := domaintest.NewServerSnapshotWithRelations(t)
t.Run("OK: with next", func(t *testing.T) {
t.Parallel()
res, err := domain.NewListServerSnapshotsWithRelationsResult(snapshots, next)
require.NoError(t, err)
assert.Equal(t, snapshots, res.ServerSnapshots())
assert.Equal(t, snapshots[0].ServerSnapshot().ID(), res.Self().ID())
assert.Equal(t, snapshots[0].ServerSnapshot().ServerKey(), res.Self().ServerKey())
assert.Equal(t, snapshots[0].ServerSnapshot().Date(), res.Self().Date())
assert.Equal(t, next.ServerSnapshot().ID(), res.Next().ID())
assert.Equal(t, next.ServerSnapshot().ServerKey(), res.Next().ServerKey())
assert.Equal(t, next.ServerSnapshot().Date(), res.Next().Date())
})
t.Run("OK: without next", func(t *testing.T) {
t.Parallel()
res, err := domain.NewListServerSnapshotsWithRelationsResult(snapshots, domain.ServerSnapshotWithRelations{})
require.NoError(t, err)
assert.Equal(t, snapshots, res.ServerSnapshots())
assert.Equal(t, snapshots[0].ServerSnapshot().ID(), res.Self().ID())
assert.Equal(t, snapshots[0].ServerSnapshot().ServerKey(), res.Self().ServerKey())
assert.Equal(t, snapshots[0].ServerSnapshot().Date(), res.Self().Date())
assert.True(t, res.Next().IsZero())
})
t.Run("OK: 0 snapshots", func(t *testing.T) {
t.Parallel()
res, err := domain.NewListServerSnapshotsWithRelationsResult(nil, domain.ServerSnapshotWithRelations{})
require.NoError(t, err)
assert.Zero(t, res.ServerSnapshots())
assert.True(t, res.Self().IsZero())
assert.True(t, res.Next().IsZero())
})
}

View File

@ -20,6 +20,7 @@ type apiHTTPHandler struct {
villageSvc *app.VillageService
ennoblementSvc *app.EnnoblementService
tribeChangeSvc *app.TribeChangeService
serverSnapshotSvc *app.ServerSnapshotService
tribeSnapshotSvc *app.TribeSnapshotService
playerSnapshotSvc *app.PlayerSnapshotService
errorRenderer apiErrorRenderer
@ -40,6 +41,7 @@ func NewAPIHTTPHandler(
villageSvc *app.VillageService,
ennoblementSvc *app.EnnoblementService,
tribeChangeSvc *app.TribeChangeService,
serverSnapshotSvc *app.ServerSnapshotService,
tribeSnapshotSvc *app.TribeSnapshotService,
playerSnapshotSvc *app.PlayerSnapshotService,
opts ...APIHTTPHandlerOption,
@ -54,6 +56,7 @@ func NewAPIHTTPHandler(
villageSvc: villageSvc,
ennoblementSvc: ennoblementSvc,
tribeChangeSvc: tribeChangeSvc,
serverSnapshotSvc: serverSnapshotSvc,
tribeSnapshotSvc: tribeSnapshotSvc,
playerSnapshotSvc: playerSnapshotSvc,
openAPISchema: sync.OnceValues(func() (*openapi3.T, error) {

View File

@ -0,0 +1,96 @@
package port
import (
"net/http"
"strconv"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/port/internal/apimodel"
)
const apiServerSnapshotSortMaxLength = 1
var apiServerSnapshotSortAllowedValues = []domain.ServerSnapshotSort{
domain.ServerSnapshotSortDateASC,
domain.ServerSnapshotSortDateDESC,
}
//nolint:gocyclo
func (h *apiHTTPHandler) ListServerServerSnapshots(
w http.ResponseWriter,
r *http.Request,
_ apimodel.VersionCodePathParam,
serverKey apimodel.ServerKeyPathParam,
params apimodel.ListServerServerSnapshotsParams,
) {
domainParams := domain.NewListServerSnapshotsParams()
if err := domainParams.SetSort([]domain.ServerSnapshotSort{domain.ServerSnapshotSortIDASC}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListServerSnapshotsErrorPath).render(w, r, err)
return
}
if err := domainParams.SetServerKeys([]string{serverKey}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListServerSnapshotsErrorPath).render(w, r, err)
return
}
if params.Sort != nil {
if err := domainParams.PrependSortString(
*params.Sort,
apiServerSnapshotSortAllowedValues,
apiServerSnapshotSortMaxLength,
); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListServerSnapshotsErrorPath).render(w, r, err)
return
}
} else {
if err := domainParams.PrependSort([]domain.ServerSnapshotSort{domain.ServerSnapshotSortDateASC}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListServerSnapshotsErrorPath).render(w, r, err)
return
}
}
if params.Limit != nil {
if err := domainParams.SetLimit(*params.Limit); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListServerSnapshotsErrorPath).render(w, r, err)
return
}
}
if params.Cursor != nil {
if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListServerSnapshotsErrorPath).render(w, r, err)
return
}
}
res, err := h.serverSnapshotSvc.ListWithRelations(r.Context(), domainParams)
if err != nil {
h.errorRenderer.render(w, r, err)
return
}
renderJSON(w, r, http.StatusOK, apimodel.NewListServerSnapshotsResponse(res))
}
func formatListServerSnapshotsErrorPath(segments []domain.ErrorPathSegment) []string {
if segments[0].Model != "ListServerSnapshotsParams" {
return nil
}
switch segments[0].Field {
case "cursor":
return []string{"$query", "cursor"}
case "limit":
return []string{"$query", "limit"}
case "sort":
path := []string{"$query", "sort"}
if segments[0].Index >= 0 {
path = append(path, strconv.Itoa(segments[0].Index))
}
return path
default:
return nil
}
}

View File

@ -0,0 +1,542 @@
package port_test
import (
"cmp"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/domain/domaintest"
"gitea.dwysokinski.me/twhelp/core/internal/port/internal/apimodel"
"github.com/brianvoe/gofakeit/v7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const endpointListServerServerSnapshots = "/v2/versions/%s/servers/%s/snapshots"
func TestListServerServerSnapshots(t *testing.T) {
t.Parallel()
handler := newAPIHTTPHandler(t)
sss := getAllServerSnapshots(t, handler)
var server serverWithVersion
sssGroupedByServerKey := make(map[string][]serverSnapshotWithServer)
for _, ss := range sss {
key := ss.Server.Key
sssGroupedByServerKey[key] = append(sssGroupedByServerKey[key], ss)
}
currentMax := -1
for _, grouped := range sssGroupedByServerKey {
if l := len(grouped); l > currentMax && l > 0 {
currentMax = l
server = grouped[0].Server
}
}
tests := []struct {
name string
reqModifier func(t *testing.T, req *http.Request)
assertResp func(t *testing.T, req *http.Request, resp *http.Response)
}{
{
name: "OK: without params",
assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.ListServerSnapshotsResponse](t, resp.Body)
assert.Zero(t, body.Cursor.Next)
assert.NotZero(t, body.Cursor.Self)
assert.NotZero(t, body.Data)
assert.True(t, slices.IsSortedFunc(body.Data, func(a, b apimodel.ServerSnapshot) int {
return cmp.Or(
a.Date.Compare(b.Date.Time),
cmp.Compare(a.Id, b.Id),
)
}))
},
},
{
name: "OK: limit=1",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("limit", "1")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.ListServerSnapshotsResponse](t, resp.Body)
assert.NotZero(t, body.Cursor.Next)
assert.NotZero(t, body.Cursor.Self)
assert.NotZero(t, body.Data)
limit, err := strconv.Atoi(req.URL.Query().Get("limit"))
require.NoError(t, err)
assert.Len(t, body.Data, limit)
},
},
{
name: "OK: limit=1 cursor",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("limit", "1")
req.URL.RawQuery = q.Encode()
resp := doCustomRequest(handler, req.Clone(req.Context()))
defer resp.Body.Close()
body := decodeJSON[apimodel.ListServerSnapshotsResponse](t, resp.Body)
require.NotEmpty(t, body.Cursor.Next)
q.Set("cursor", body.Cursor.Next)
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.ListServerSnapshotsResponse](t, resp.Body)
assert.Equal(t, req.URL.Query().Get("cursor"), body.Cursor.Self)
limit, err := strconv.Atoi(req.URL.Query().Get("limit"))
require.NoError(t, err)
assert.Len(t, body.Data, limit)
},
},
{
name: "OK: sort=[date:DESC]",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("sort", "date:DESC")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.ListServerSnapshotsResponse](t, resp.Body)
assert.Zero(t, body.Cursor.Next)
assert.NotZero(t, body.Cursor.Self)
assert.NotZero(t, body.Data)
assert.True(t, slices.IsSortedFunc(body.Data, func(a, b apimodel.ServerSnapshot) int {
return cmp.Or(
a.Date.Compare(b.Date.Time)*-1,
cmp.Compare(a.Id, b.Id),
)
}))
},
},
{
name: "ERR: limit is not an integer",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("limit", "asd")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: "invalid-param-format",
Message: fmt.Sprintf(
"error binding string parameter: strconv.ParseInt: parsing \"%s\": invalid syntax",
req.URL.Query().Get("limit"),
),
Path: []string{"$query", "limit"},
},
},
}, body)
},
},
{
name: "ERR: limit < 1",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("limit", "0")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
limit, err := strconv.Atoi(req.URL.Query().Get("limit"))
require.NoError(t, err)
domainErr := domain.MinGreaterEqualError{
Min: 1,
Current: limit,
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"current": float64(domainErr.Current),
"min": float64(domainErr.Min),
},
Path: []string{"$query", "limit"},
},
},
}, body)
},
},
{
name: fmt.Sprintf("ERR: limit > %d", domain.ServerSnapshotListMaxLimit),
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("limit", strconv.Itoa(domain.ServerSnapshotListMaxLimit+1))
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
limit, err := strconv.Atoi(req.URL.Query().Get("limit"))
require.NoError(t, err)
domainErr := domain.MaxLessEqualError{
Max: domain.ServerSnapshotListMaxLimit,
Current: limit,
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"current": float64(domainErr.Current),
"max": float64(domainErr.Max),
},
Path: []string{"$query", "limit"},
},
},
}, body)
},
},
{
name: "ERR: len(cursor) < 1",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("cursor", "")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
domainErr := domain.LenOutOfRangeError{
Min: 1,
Max: 1000,
Current: len(req.URL.Query().Get("cursor")),
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"current": float64(domainErr.Current),
"max": float64(domainErr.Max),
"min": float64(domainErr.Min),
},
Path: []string{"$query", "cursor"},
},
},
}, body)
},
},
{
name: "ERR: len(cursor) > 1000",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("cursor", gofakeit.LetterN(1001))
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
domainErr := domain.LenOutOfRangeError{
Min: 1,
Max: 1000,
Current: len(req.URL.Query().Get("cursor")),
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"current": float64(domainErr.Current),
"max": float64(domainErr.Max),
"min": float64(domainErr.Min),
},
Path: []string{"$query", "cursor"},
},
},
}, body)
},
},
{
name: "ERR: invalid cursor",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("cursor", gofakeit.LetterN(100))
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
var domainErr domain.Error
require.ErrorAs(t, domain.ErrInvalidCursor, &domainErr)
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Path: []string{"$query", "cursor"},
},
},
}, body)
},
},
{
name: "ERR: len(sort) > 1",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Add("sort", "date:DESC")
q.Add("sort", "date:ASC")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
domainErr := domain.LenOutOfRangeError{
Min: 0,
Max: 1,
Current: len(req.URL.Query()["sort"]),
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"current": float64(domainErr.Current),
"max": float64(domainErr.Max),
"min": float64(domainErr.Min),
},
Path: []string{"$query", "sort"},
},
},
}, body)
},
},
{
name: "ERR: invalid sort",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Add("sort", "date:")
req.URL.RawQuery = q.Encode()
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
domainErr := domain.UnsupportedSortStringError{
Sort: req.URL.Query()["sort"][0],
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"sort": domainErr.Sort,
},
Path: []string{"$query", "sort", "0"},
},
},
}, body)
},
},
{
name: "ERR: version not found",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(
endpointListServerServerSnapshots,
randInvalidVersionCode(t, handler),
server.Key,
)
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
pathSegments := strings.Split(req.URL.Path, "/")
require.Len(t, pathSegments, 7)
domainErr := domain.VersionNotFoundError{
VersionCode: pathSegments[3],
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"code": domainErr.VersionCode,
},
},
},
}, body)
},
},
{
name: "ERR: server not found",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(
endpointListServerServerSnapshots,
server.Version.Code,
domaintest.RandServerKey(),
)
},
assertResp: func(t *testing.T, req *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
// body
body := decodeJSON[apimodel.ErrorResponse](t, resp.Body)
pathSegments := strings.Split(req.URL.Path, "/")
require.Len(t, pathSegments, 7)
domainErr := domain.ServerNotFoundError{
Key: pathSegments[5],
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"key": domainErr.Key,
},
},
},
}, body)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(
http.MethodGet,
fmt.Sprintf(endpointListServerServerSnapshots, server.Version.Code, server.Key),
nil,
)
if tt.reqModifier != nil {
tt.reqModifier(t, req)
}
resp := doCustomRequest(handler, req)
defer resp.Body.Close()
tt.assertResp(t, req, resp)
})
}
}
type serverSnapshotWithServer struct {
apimodel.ServerSnapshot
Server serverWithVersion
}
func getAllServerSnapshots(tb testing.TB, h http.Handler) []serverSnapshotWithServer {
tb.Helper()
servers := getAllServers(tb, h)
var sss []serverSnapshotWithServer
for _, s := range servers {
resp := doRequest(h, http.MethodGet, fmt.Sprintf(
endpointListServerServerSnapshots,
s.Version.Code,
s.Key,
), nil)
require.Equal(tb, http.StatusOK, resp.StatusCode)
for _, ts := range decodeJSON[apimodel.ListServerSnapshotsResponse](tb, resp.Body).Data {
sss = append(sss, serverSnapshotWithServer{
ServerSnapshot: ts,
Server: s,
})
}
_ = resp.Body.Close()
}
require.NotZero(tb, sss)
return sss
}

View File

@ -40,6 +40,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h
villageRepo := adapter.NewVillageBunRepository(bunDB)
ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB)
tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB)
serverSnapshotRepo := adapter.NewServerSnapshotBunRepository(bunDB)
tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(bunDB)
playerSnapshotRepo := adapter.NewPlayerSnapshotBunRepository(bunDB)
@ -51,6 +52,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h
app.NewVillageService(villageRepo, nil, nil),
app.NewEnnoblementService(ennoblementRepo, nil, nil),
app.NewTribeChangeService(tribeChangeRepo),
app.NewServerSnapshotService(serverSnapshotRepo, nil, nil),
app.NewTribeSnapshotService(tribeSnapshotRepo, nil, nil),
app.NewPlayerSnapshotService(playerSnapshotRepo, nil, nil),
cfg.options...,

View File

@ -0,0 +1,43 @@
package apimodel
import (
"gitea.dwysokinski.me/twhelp/core/internal/domain"
oapitypes "github.com/oapi-codegen/runtime/types"
)
func NewServerSnapshot(withRelations domain.ServerSnapshotWithRelations) ServerSnapshot {
ss := withRelations.ServerSnapshot()
return ServerSnapshot{
Date: oapitypes.Date{Time: ss.Date()},
Id: ss.ID(),
NumActivePlayers: ss.NumActivePlayers(),
NumActiveTribes: ss.NumActiveTribes(),
NumBarbarianVillages: ss.NumBarbarianVillages(),
NumBonusVillages: ss.NumBonusVillages(),
NumInactivePlayers: ss.NumInactivePlayers(),
NumInactiveTribes: ss.NumInactiveTribes(),
NumPlayerVillages: ss.NumPlayerVillages(),
NumPlayers: ss.NumPlayers(),
NumTribes: ss.NumTribes(),
NumVillages: ss.NumVillages(),
Server: NewServerMeta(withRelations.Server()),
}
}
func NewListServerSnapshotsResponse(res domain.ListServerSnapshotsWithRelationsResult) ListServerSnapshotsResponse {
sss := res.ServerSnapshots()
resp := ListServerSnapshotsResponse{
Data: make([]ServerSnapshot, 0, len(sss)),
Cursor: Cursor{
Next: res.Next().Encode(),
Self: res.Self().Encode(),
},
}
for _, ss := range sss {
resp.Data = append(resp.Data, NewServerSnapshot(ss))
}
return resp
}

View File

@ -7393,6 +7393,64 @@
new_tribe_id: 2
server_key: pl169
created_at: 2021-09-10T20:01:11.000Z
- model: ServerSnapshot
rows:
- id: 10000
server_key: de188
num_players: 0
num_active_players: 180
num_inactive_players: 0
num_tribes: 0
num_active_tribes: 76
num_inactive_tribes: 0
num_villages: 16180
num_player_villages: 15000
num_barbarian_villages: 1180
num_bonus_villages: 512
date: 2024-05-01T05:15:25.154992Z
created_at: 2024-05-01T05:15:25.154994Z
- id: 10001
server_key: de188
num_players: 0
num_active_players: 180
num_inactive_players: 0
num_tribes: 0
num_active_tribes: 76
num_inactive_tribes: 0
num_villages: 16180
num_player_villages: 15000
num_barbarian_villages: 1180
num_bonus_villages: 512
date: 2024-05-02T05:15:25.154992Z
created_at: 2024-05-02T05:15:25.154994Z
- id: 20000
server_key: it70
num_players: 0
num_active_players: 180
num_inactive_players: 0
num_tribes: 0
num_active_tribes: 76
num_inactive_tribes: 0
num_villages: 16180
num_player_villages: 15000
num_barbarian_villages: 1180
num_bonus_villages: 512
date: 2024-05-03T05:15:25.154992Z
created_at: 2024-05-03T05:15:25.154994Z
- id: 20001
server_key: it70
num_players: 0
num_active_players: 180
num_inactive_players: 0
num_tribes: 0
num_active_tribes: 76
num_inactive_tribes: 0
num_villages: 16180
num_player_villages: 15000
num_barbarian_villages: 1180
num_bonus_villages: 512
date: 2024-05-04T05:15:25.154992Z
created_at: 2024-05-04T05:15:25.154994Z
- model: TribeSnapshot
rows:
- rank_att: 1

View File

@ -5,8 +5,12 @@
url: https://pl169.plemiona.pl
open: true
special: false
num_players: 10000
num_active_players: 2001
num_inactive_players: 7999
num_tribes: 500
num_active_tribes: 214
num_inactive_tribes: 286
num_villages: 49074
num_player_villages: 48500
num_barbarian_villages: 1574

View File

@ -39,8 +39,6 @@ spec:
key: rabbitmq-connection-string
- name: AUTO_MAX_PROCS
value: "true"
- name: API_OPENAPI_SERVERS
value: https://twhelp.app,https://tribalwarshelp.com
resources:
requests:
cpu: 20m

View File

@ -29,3 +29,5 @@ spec:
key: rabbitmq-connection-string
- name: AUTO_MAX_PROCS
value: "true"
- name: API_OPENAPI_SERVERS
value: https://twhelp.app,https://tribalwarshelp.com