From bf2b3b178c870808ef9ed133c4d0f66e3d503f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Thu, 29 Feb 2024 06:03:36 +0000 Subject: [PATCH] feat: api - GET /api/v2/versions/{versionCode}/servers/{serverKey}/players - new query param 'name' (#9) Reviewed-on: https://gitea.dwysokinski.me/twhelp/corev3/pulls/9 --- api/openapi3.yml | 11 ++ internal/adapter/repository_bun_player.go | 4 + internal/adapter/repository_player_test.go | 37 ++++- internal/adapter/repository_tribe_test.go | 4 +- internal/domain/player.go | 35 ++++ internal/domain/player_test.go | 118 ++++++++++++++ internal/port/handler_http_api_player.go | 13 ++ internal/port/handler_http_api_player_test.go | 149 ++++++++++++++++++ 8 files changed, 368 insertions(+), 3 deletions(-) diff --git a/api/openapi3.yml b/api/openapi3.yml index 8f614c7..f542af4 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -182,6 +182,7 @@ paths: - $ref: "#/components/parameters/LimitQueryParam" - $ref: "#/components/parameters/PlayerDeletedQueryParam" - $ref: "#/components/parameters/PlayerSortQueryParam" + - $ref: "#/components/parameters/PlayerNameQueryParam" responses: 200: $ref: "#/components/responses/ListPlayersResponse" @@ -1141,6 +1142,16 @@ components: - deletedAt:ASC - deletedAt:DESC maxItems: 2 + PlayerNameQueryParam: + name: name + in: query + schema: + type: array + items: + type: string + minLength: 1 + maxLength: 150 + maxItems: 100 VersionCodePathParam: in: path name: versionCode diff --git a/internal/adapter/repository_bun_player.go b/internal/adapter/repository_bun_player.go index 19bdced..0bd9702 100644 --- a/internal/adapter/repository_bun_player.go +++ b/internal/adapter/repository_bun_player.go @@ -168,6 +168,10 @@ func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Where("player.server_key IN (?)", bun.In(serverKeys)) } + if names := a.params.Names(); len(names) > 0 { + q = q.Where("player.name IN (?)", bun.In(names)) + } + if deleted := a.params.Deleted(); deleted.Valid { if deleted.V { q = q.Where("player.deleted_at IS NOT NULL") diff --git a/internal/adapter/repository_player_test.go b/internal/adapter/repository_player_test.go index efd5235..d0842a7 100644 --- a/internal/adapter/repository_player_test.go +++ b/internal/adapter/repository_player_test.go @@ -357,7 +357,7 @@ func testPlayerRepository(t *testing.T, newRepos func(t *testing.T) repositories serverKeys := params.ServerKeys() players := res.Players() - assert.NotEmpty(t, players) + assert.Len(t, players, len(ids)) for _, p := range players { assert.True(t, slices.Contains(ids, p.ID())) assert.True(t, slices.Contains(serverKeys, p.ServerKey())) @@ -368,6 +368,41 @@ func testPlayerRepository(t *testing.T, newRepos func(t *testing.T) repositories require.NoError(t, err) }, }, + { + name: "OK: names serverKeys", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + + params := domain.NewListPlayersParams() + + res, err := repos.player.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, len(res.Players())) + randPlayer := res.Players()[0] + + require.NoError(t, params.SetNames([]string{randPlayer.Name()})) + require.NoError(t, params.SetServerKeys([]string{randPlayer.ServerKey()})) + + return params + }, + assertResult: func(t *testing.T, params domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + + names := params.Names() + serverKeys := params.ServerKeys() + + players := res.Players() + assert.Len(t, players, len(names)) + for _, p := range players { + assert.True(t, slices.Contains(names, p.Name())) + assert.True(t, slices.Contains(serverKeys, p.ServerKey())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, { name: "OK: deleted=true", params: func(t *testing.T) domain.ListPlayersParams { diff --git a/internal/adapter/repository_tribe_test.go b/internal/adapter/repository_tribe_test.go index 44f6ed0..2e10f52 100644 --- a/internal/adapter/repository_tribe_test.go +++ b/internal/adapter/repository_tribe_test.go @@ -449,7 +449,7 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) serverKeys := params.ServerKeys() tribes := res.Tribes() - assert.NotEmpty(t, tribes) + assert.Len(t, tribes, len(ids)) for _, tr := range tribes { assert.True(t, slices.Contains(ids, tr.ID())) assert.True(t, slices.Contains(serverKeys, tr.ServerKey())) @@ -484,7 +484,7 @@ func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) serverKeys := params.ServerKeys() tribes := res.Tribes() - assert.NotEmpty(t, tribes) + assert.Len(t, tribes, len(tags)) for _, tr := range tribes { assert.True(t, slices.Contains(tags, tr.Tag())) assert.True(t, slices.Contains(serverKeys, tr.ServerKey())) diff --git a/internal/domain/player.go b/internal/domain/player.go index a942b3c..810a917 100644 --- a/internal/domain/player.go +++ b/internal/domain/player.go @@ -629,6 +629,7 @@ func (pc PlayerCursor) Encode() string { type ListPlayersParams struct { ids []int serverKeys []string + names []string deleted NullBool sort []PlayerSort cursor PlayerCursor @@ -680,6 +681,40 @@ func (params *ListPlayersParams) SetServerKeys(serverKeys []string) error { return nil } +func (params *ListPlayersParams) Names() []string { + return params.names +} + +const ( + playerNamesMinLength = 1 + playerNamesMaxLength = 100 +) + +func (params *ListPlayersParams) SetNames(names []string) error { + if err := validateSliceLen(names, playerNamesMinLength, playerNamesMaxLength); err != nil { + return ValidationError{ + Model: listPlayersParamsModelName, + Field: "names", + Err: err, + } + } + + for i, n := range names { + if err := validateStringLen(n, playerNameMinLength, playerNameMaxLength); err != nil { + return SliceElementValidationError{ + Model: listPlayersParamsModelName, + Field: "names", + Index: i, + Err: err, + } + } + } + + params.names = names + + return nil +} + func (params *ListPlayersParams) Deleted() NullBool { return params.deleted } diff --git a/internal/domain/player_test.go b/internal/domain/player_test.go index bdba988..95aafb4 100644 --- a/internal/domain/player_test.go +++ b/internal/domain/player_test.go @@ -474,6 +474,124 @@ func TestListPlayersParams_SetIDs(t *testing.T) { } } +func TestListPlayersParams_SetNames(t *testing.T) { + t.Parallel() + + type args struct { + names []string + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + names: []string{ + gofakeit.LetterN(50), + gofakeit.LetterN(50), + gofakeit.LetterN(50), + gofakeit.LetterN(50), + }, + }, + }, + { + name: "ERR: len(names) < 1", + args: args{ + names: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayersParams", + Field: "names", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 100, + Current: 0, + }, + }, + }, + { + name: "ERR: len(names) > 100", + args: args{ + names: func() []string { + names := make([]string, 101) + for i := range names { + names[i] = gofakeit.LetterN(50) + } + return names + }(), + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayersParams", + Field: "names", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 100, + Current: 101, + }, + }, + }, + { + name: "ERR: len(name[1]) < 1", + args: args{ + names: []string{ + gofakeit.LetterN(50), + "", + gofakeit.LetterN(50), + gofakeit.LetterN(50), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListPlayersParams", + Field: "names", + Index: 1, + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 150, + Current: 0, + }, + }, + }, + { + name: "ERR: len(name[2]) > 150", + args: args{ + names: []string{ + gofakeit.LetterN(50), + gofakeit.LetterN(50), + gofakeit.LetterN(151), + gofakeit.LetterN(50), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListPlayersParams", + Field: "names", + Index: 2, + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 150, + Current: 151, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListPlayersParams() + + require.ErrorIs(t, params.SetNames(tt.args.names), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.names, params.Names()) + }) + } +} + func TestListPlayersParams_SetSort(t *testing.T) { t.Parallel() diff --git a/internal/port/handler_http_api_player.go b/internal/port/handler_http_api_player.go index b33f494..8db6e39 100644 --- a/internal/port/handler_http_api_player.go +++ b/internal/port/handler_http_api_player.go @@ -35,6 +35,13 @@ func (h *apiHTTPHandler) ListPlayers( return } + if params.Name != nil { + if err := domainParams.SetNames(*params.Name); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListPlayersErrorPath).render(w, r, err) + return + } + } + if params.Deleted != nil { if err := domainParams.SetDeleted(domain.NullBool{ V: *params.Deleted, @@ -80,6 +87,12 @@ func formatListPlayersErrorPath(segments []errorPathSegment) []string { return []string{"$query", "limit"} case "deleted": return []string{"$query", "deleted"} + case "names": + path := []string{"$query", "name"} + if segments[0].index >= 0 { + path = append(path, strconv.Itoa(segments[0].index)) + } + return path case "sort": path := []string{"$query", "sort"} if segments[0].index >= 0 { diff --git a/internal/port/handler_http_api_player_test.go b/internal/port/handler_http_api_player_test.go index dade70e..d0ebf00 100644 --- a/internal/port/handler_http_api_player_test.go +++ b/internal/port/handler_http_api_player_test.go @@ -176,6 +176,43 @@ func TestListPlayers(t *testing.T) { })) }, }, + { + name: "OK: tag", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + q := req.URL.Query() + + for _, p := range players { + if p.Server.Key == server.Key { + q.Add("name", p.Name) + } + + if len(q["name"]) == 2 { + break + } + } + + require.NotEmpty(t, q["name"]) + + 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.ListPlayersResponse](t, resp.Body) + assert.NotZero(t, body.Cursor.Self) + assert.NotZero(t, body.Data) + names := req.URL.Query()["name"] + assert.Len(t, body.Data, len(names)) + for _, p := range body.Data { + assert.True(t, slices.Contains(names, p.Name)) + } + }, + }, { name: "OK: deleted=false", reqModifier: func(t *testing.T, req *http.Request) { @@ -490,6 +527,118 @@ func TestListPlayers(t *testing.T) { }, body) }, }, + { + name: "ERR: len(name) > 100", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + for range 101 { + q.Add("name", gofakeit.LetterN(50)) + } + 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: 100, + Current: len(req.URL.Query()["name"]), + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "max": float64(domainErr.Max), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "name"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: len(name[1]) < 1", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Add("name", gofakeit.LetterN(50)) + q.Add("name", "") + 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: 150, + Current: len(req.URL.Query()["name"][1]), + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "max": float64(domainErr.Max), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "name", "1"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: len(name[1]) > 150", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Add("name", gofakeit.LetterN(50)) + q.Add("name", gofakeit.LetterN(151)) + 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: 150, + Current: len(req.URL.Query()["name"][1]), + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "max": float64(domainErr.Max), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "name", "1"}, + }, + }, + }, body) + }, + }, { name: "ERR: deleted is not a valid boolean", reqModifier: func(t *testing.T, req *http.Request) {