feat: new endpoint /api/v2/versions/{versionCode}/servers/{serverKey}/players/{playerId}/snapshots (#31)
ci/woodpecker/push/govulncheck Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details

Reviewed-on: twhelp/corev3#31
This commit is contained in:
Dawid Wysokiński 2024-03-20 06:23:58 +00:00
parent 387fe30b82
commit 7ba172f7a4
9 changed files with 881 additions and 32 deletions

View File

@ -463,6 +463,27 @@ paths:
$ref: "#/components/responses/ListTribeSnapshotsResponse"
default:
$ref: "#/components/responses/ErrorResponse"
/v2/versions/{versionCode}/servers/{serverKey}/players/{playerId}/snapshots:
get:
operationId: listPlayerPlayerSnapshots
tags:
- versions
- servers
- players
- snapshots
description: List the given player's snapshots
parameters:
- $ref: "#/components/parameters/VersionCodePathParam"
- $ref: "#/components/parameters/ServerKeyPathParam"
- $ref: "#/components/parameters/PlayerIdPathParam"
- $ref: "#/components/parameters/CursorQueryParam"
- $ref: "#/components/parameters/LimitQueryParam"
- $ref: "#/components/parameters/PlayerSnapshotSortQueryParam"
responses:
200:
$ref: "#/components/responses/ListPlayerSnapshotsResponse"
default:
$ref: "#/components/responses/ErrorResponse"
components:
schemas:
DomainErrorCode:
@ -1542,6 +1563,32 @@ components:
type: integer
opponentsDefeated:
$ref: "#/components/schemas/TribeOpponentsDefeated"
PlayerSnapshot:
type: object
required:
- id
- numVillages
- points
- rank
- date
- opponentsDefeated
- player
properties:
id:
$ref: "#/components/schemas/IntId"
numVillages:
type: integer
points:
type: integer
rank:
type: integer
date:
type: string
format: date
opponentsDefeated:
$ref: "#/components/schemas/PlayerOpponentsDefeated"
player:
$ref: "#/components/schemas/PlayerMeta"
CursorString:
type: string
minLength: 1
@ -1719,6 +1766,20 @@ components:
- date:ASC
- date:DESC
maxItems: 2
PlayerSnapshotSortQueryParam:
name: sort
in: query
description: Order matters!
schema:
type: array
default:
- date:ASC
items:
type: string
enum:
- date:ASC
- date:DESC
maxItems: 2
SinceQueryParam:
name: since
in: query
@ -1972,6 +2033,21 @@ components:
type: array
items:
$ref: "#/components/schemas/TribeSnapshot"
ListPlayerSnapshotsResponse:
description: ""
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationResponse"
- type: object
required:
- data
properties:
data:
type: array
items:
$ref: "#/components/schemas/PlayerSnapshot"
ErrorResponse:
description: Default error response.
content:

View File

@ -114,6 +114,7 @@ var cmdServe = &cli.Command{
ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB)
tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB)
tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(bunDB)
playerSnapshotRepo := adapter.NewPlayerSnapshotBunRepository(bunDB)
// services
versionSvc := app.NewVersionService(versionRepo)
@ -124,6 +125,7 @@ var cmdServe = &cli.Command{
villageSvc := app.NewVillageService(villageRepo, nil, nil)
ennoblementSvc := app.NewEnnoblementService(ennoblementRepo, nil, nil)
tribeSnapshotSvc := app.NewTribeSnapshotService(tribeSnapshotRepo, tribeSvc, nil)
playerSnapshotSvc := app.NewPlayerSnapshotService(playerSnapshotRepo, playerSvc, nil)
// health
h := health.New()
@ -159,6 +161,7 @@ var cmdServe = &cli.Command{
ennoblementSvc,
tribeChangeSvc,
tribeSnapshotSvc,
playerSnapshotSvc,
port.WithOpenAPIConfig(oapiCfg),
))
})

View File

@ -11,6 +11,10 @@ type PlayerSnapshotRepository interface {
// Create persists player snapshots in a store (e.g. Postgres).
// Duplicates are ignored.
Create(ctx context.Context, params ...domain.CreatePlayerSnapshotParams) error
ListWithRelations(
ctx context.Context,
params domain.ListPlayerSnapshotsParams,
) (domain.ListPlayerSnapshotsWithRelationsResult, error)
}
type PlayerSnapshotService struct {
@ -92,3 +96,10 @@ func (svc *PlayerSnapshotService) Create(
return nil
}
func (svc *PlayerSnapshotService) ListWithRelations(
ctx context.Context,
params domain.ListPlayerSnapshotsParams,
) (domain.ListPlayerSnapshotsWithRelationsResult, error) {
return svc.repo.ListWithRelations(ctx, params)
}

View File

@ -13,16 +13,17 @@ import (
)
type apiHTTPHandler struct {
versionSvc *app.VersionService
serverSvc *app.ServerService
tribeSvc *app.TribeService
playerSvc *app.PlayerService
villageSvc *app.VillageService
ennoblementSvc *app.EnnoblementService
tribeChangeSvc *app.TribeChangeService
tribeSnapshotSvc *app.TribeSnapshotService
errorRenderer apiErrorRenderer
openAPISchema func() (*openapi3.T, error)
versionSvc *app.VersionService
serverSvc *app.ServerService
tribeSvc *app.TribeService
playerSvc *app.PlayerService
villageSvc *app.VillageService
ennoblementSvc *app.EnnoblementService
tribeChangeSvc *app.TribeChangeService
tribeSnapshotSvc *app.TribeSnapshotService
playerSnapshotSvc *app.PlayerSnapshotService
errorRenderer apiErrorRenderer
openAPISchema func() (*openapi3.T, error)
}
func WithOpenAPIConfig(oapiCfg OpenAPIConfig) APIHTTPHandlerOption {
@ -40,19 +41,21 @@ func NewAPIHTTPHandler(
ennoblementSvc *app.EnnoblementService,
tribeChangeSvc *app.TribeChangeService,
tribeSnapshotSvc *app.TribeSnapshotService,
playerSnapshotSvc *app.PlayerSnapshotService,
opts ...APIHTTPHandlerOption,
) http.Handler {
cfg := newAPIHTTPHandlerConfig(opts...)
h := &apiHTTPHandler{
versionSvc: versionSvc,
serverSvc: serverSvc,
tribeSvc: tribeSvc,
playerSvc: playerSvc,
villageSvc: villageSvc,
ennoblementSvc: ennoblementSvc,
tribeChangeSvc: tribeChangeSvc,
tribeSnapshotSvc: tribeSnapshotSvc,
versionSvc: versionSvc,
serverSvc: serverSvc,
tribeSvc: tribeSvc,
playerSvc: playerSvc,
villageSvc: villageSvc,
ennoblementSvc: ennoblementSvc,
tribeChangeSvc: tribeChangeSvc,
tribeSnapshotSvc: tribeSnapshotSvc,
playerSnapshotSvc: playerSnapshotSvc,
openAPISchema: sync.OnceValues(func() (*openapi3.T, error) {
return getOpenAPISchema(cfg.openAPI)
}),

View File

@ -0,0 +1,91 @@
package port
import (
"net/http"
"strconv"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel"
)
//nolint:gocyclo
func (h *apiHTTPHandler) ListPlayerPlayerSnapshots(
w http.ResponseWriter,
r *http.Request,
_ apimodel.VersionCodePathParam,
serverKey apimodel.ServerKeyPathParam,
playerID apimodel.PlayerIdPathParam,
params apimodel.ListPlayerPlayerSnapshotsParams,
) {
domainParams := domain.NewListPlayerSnapshotsParams()
if err := domainParams.SetSort([]domain.PlayerSnapshotSort{domain.PlayerSnapshotSortIDASC}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
if err := domainParams.SetPlayerIDs([]int{playerID}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
if params.Sort != nil {
if err := domainParams.PrependSortString(*params.Sort); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
} else {
if err := domainParams.PrependSortString([]string{domain.PlayerSnapshotSortDateASC.String()}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
}
if err := domainParams.SetServerKeys([]string{serverKey}); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
if params.Limit != nil {
if err := domainParams.SetLimit(*params.Limit); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
}
if params.Cursor != nil {
if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil {
h.errorRenderer.withErrorPathFormatter(formatListPlayerSnapshotsErrorPath).render(w, r, err)
return
}
}
res, err := h.playerSnapshotSvc.ListWithRelations(r.Context(), domainParams)
if err != nil {
h.errorRenderer.render(w, r, err)
return
}
renderJSON(w, r, http.StatusOK, apimodel.NewListPlayerSnapshotsResponse(res))
}
func formatListPlayerSnapshotsErrorPath(segments []domain.ErrorPathSegment) []string {
if segments[0].Model != "ListPlayerSnapshotsParams" {
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,626 @@
package port_test
import (
"cmp"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest"
"gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel"
"github.com/brianvoe/gofakeit/v7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const endpointListPlayerPlayerSnapshots = "/v2/versions/%s/servers/%s/players/%d/snapshots"
func TestListPlayerPlayerSnapshots(t *testing.T) {
t.Parallel()
handler := newAPIHTTPHandler(t)
pss := getAllPlayerSnapshots(t, handler)
var server serverWithVersion
var player apimodel.PlayerMeta
pssGroupedByPlayerIDAndServerKey := make(map[string][]playerSnapshotWithServer)
for _, ps := range pss {
key := fmt.Sprintf("%d-%s", ps.Player.Id, ps.Server.Key)
pssGroupedByPlayerIDAndServerKey[key] = append(pssGroupedByPlayerIDAndServerKey[key], ps)
}
currentMax := -1
for _, grouped := range pssGroupedByPlayerIDAndServerKey {
if l := len(grouped); l > currentMax && l > 0 {
currentMax = l
server = grouped[0].Server
player = grouped[0].Player
}
}
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.ListPlayerSnapshotsResponse](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.PlayerSnapshot) 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.ListPlayerSnapshotsResponse](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.ListPlayerSnapshotsResponse](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.ListPlayerSnapshotsResponse](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.ListPlayerSnapshotsResponse](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.PlayerSnapshot) int {
return cmp.Or(
a.Date.Compare(b.Date.Time)*-1,
cmp.Compare(a.Id, b.Id),
)
}))
},
},
{
name: "ERR: limit is not a string",
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.PlayerSnapshotListMaxLimit),
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Set("limit", strconv.Itoa(domain.PlayerSnapshotListMaxLimit+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.PlayerSnapshotListMaxLimit,
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) > 2",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
q := req.URL.Query()
q.Add("sort", "date:DESC")
q.Add("sort", "date:ASC")
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: 1,
Max: 2,
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: sort conflict",
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)
q := req.URL.Query()
domainErr := domain.SortConflictError{
Sort: [2]string{q["sort"][0], q["sort"][1]},
}
paramSort := make([]any, len(domainErr.Sort))
for i, s := range domainErr.Sort {
paramSort[i] = s
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"sort": paramSort,
},
Path: []string{"$query", "sort"},
},
},
}, body)
},
},
{
name: "ERR: version not found",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(
endpointListPlayerPlayerSnapshots,
randInvalidVersionCode(t, handler),
server.Key,
player.Id,
)
},
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, 9)
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(
endpointListPlayerPlayerSnapshots,
server.Version.Code,
domaintest.RandServerKey(),
player.Id,
)
},
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, 9)
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)
},
},
{
name: "ERR: player not found",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(
endpointListPlayerPlayerSnapshots,
server.Version.Code,
server.Key,
domaintest.RandID(),
)
},
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, 9)
id, err := strconv.Atoi(pathSegments[7])
require.NoError(t, err)
domainErr := domain.PlayerNotFoundError{
ID: id,
ServerKey: pathSegments[5],
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: apimodel.ErrorCode(domainErr.Code()),
Message: domainErr.Error(),
Params: map[string]any{
"id": float64(domainErr.ID),
"serverKey": domainErr.ServerKey,
},
},
},
}, body)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(
http.MethodGet,
fmt.Sprintf(endpointListPlayerPlayerSnapshots, server.Version.Code, server.Key, player.Id),
nil,
)
if tt.reqModifier != nil {
tt.reqModifier(t, req)
}
resp := doCustomRequest(handler, req)
defer resp.Body.Close()
tt.assertResp(t, req, resp)
})
}
}
type playerSnapshotWithServer struct {
apimodel.PlayerSnapshot
Server serverWithVersion
}
func getAllPlayerSnapshots(tb testing.TB, h http.Handler) []playerSnapshotWithServer {
tb.Helper()
players := getAllPlayers(tb, h)
var pss []playerSnapshotWithServer
for _, p := range players {
resp := doRequest(h, http.MethodGet, fmt.Sprintf(
endpointListPlayerPlayerSnapshots,
p.Server.Version.Code,
p.Server.Key,
p.Id,
), nil)
require.Equal(tb, http.StatusOK, resp.StatusCode)
for _, ps := range decodeJSON[apimodel.ListPlayerSnapshotsResponse](tb, resp.Body).Data {
pss = append(pss, playerSnapshotWithServer{
PlayerSnapshot: ps,
Server: p.Server,
})
}
_ = resp.Body.Close()
}
require.NotZero(tb, pss)
return pss
}

View File

@ -41,6 +41,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h
ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB)
tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB)
tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(bunDB)
playerSnapshotRepo := adapter.NewPlayerSnapshotBunRepository(bunDB)
return port.NewAPIHTTPHandler(
app.NewVersionService(versionRepo),
@ -51,6 +52,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h
app.NewEnnoblementService(ennoblementRepo, nil, nil),
app.NewTribeChangeService(tribeChangeRepo),
app.NewTribeSnapshotService(tribeSnapshotRepo, nil, nil),
app.NewPlayerSnapshotService(playerSnapshotRepo, nil, nil),
cfg.options...,
)
}

View File

@ -0,0 +1,37 @@
package apimodel
import (
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
oapitypes "github.com/oapi-codegen/runtime/types"
)
func NewPlayerSnapshot(withRelations domain.PlayerSnapshotWithRelations) PlayerSnapshot {
ps := withRelations.PlayerSnapshot()
return PlayerSnapshot{
Date: oapitypes.Date{Time: ps.Date()},
Id: ps.ID(),
NumVillages: ps.NumVillages(),
OpponentsDefeated: NewPlayerOpponentsDefeated(ps.OD()),
Player: NewPlayerMeta(withRelations.Player().WithRelations(withRelations.Tribe())),
Points: ps.Points(),
Rank: ps.Rank(),
}
}
func NewListPlayerSnapshotsResponse(res domain.ListPlayerSnapshotsWithRelationsResult) ListPlayerSnapshotsResponse {
pss := res.PlayerSnapshots()
resp := ListPlayerSnapshotsResponse{
Data: make([]PlayerSnapshot, 0, len(pss)),
Cursor: Cursor{
Next: res.Next().Encode(),
Self: res.Self().Encode(),
},
}
for _, ps := range pss {
resp.Data = append(resp.Data, NewPlayerSnapshot(ps))
}
return resp
}

View File

@ -6,34 +6,34 @@ import (
)
func NewTribeSnapshot(withRelations domain.TribeSnapshotWithRelations) TribeSnapshot {
tc := withRelations.TribeSnapshot()
ts := withRelations.TribeSnapshot()
return TribeSnapshot{
AllPoints: tc.AllPoints(),
Date: oapitypes.Date{Time: tc.Date()},
Dominance: tc.Dominance(),
Id: tc.ID(),
NumMembers: tc.NumMembers(),
NumVillages: tc.NumVillages(),
OpponentsDefeated: NewTribeOpponentsDefeated(tc.OD()),
Points: tc.Points(),
Rank: tc.Rank(),
AllPoints: ts.AllPoints(),
Date: oapitypes.Date{Time: ts.Date()},
Dominance: ts.Dominance(),
Id: ts.ID(),
NumMembers: ts.NumMembers(),
NumVillages: ts.NumVillages(),
OpponentsDefeated: NewTribeOpponentsDefeated(ts.OD()),
Points: ts.Points(),
Rank: ts.Rank(),
Tribe: NewTribeMeta(withRelations.Tribe()),
}
}
func NewListTribeSnapshotsResponse(res domain.ListTribeSnapshotsWithRelationsResult) ListTribeSnapshotsResponse {
tcs := res.TribeSnapshots()
tss := res.TribeSnapshots()
resp := ListTribeSnapshotsResponse{
Data: make([]TribeSnapshot, 0, len(tcs)),
Data: make([]TribeSnapshot, 0, len(tss)),
Cursor: Cursor{
Next: res.Next().Encode(),
Self: res.Self().Encode(),
},
}
for _, tc := range tcs {
resp.Data = append(resp.Data, NewTribeSnapshot(tc))
for _, ts := range tss {
resp.Data = append(resp.Data, NewTribeSnapshot(ts))
}
return resp