diff --git a/api/openapi3.yml b/api/openapi3.yml index e15dcd2..527442d 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -263,7 +263,46 @@ paths: $ref: "#/components/responses/GetVillageResponse" default: $ref: "#/components/responses/ErrorResponse" - + /v2/versions/{versionCode}/servers/{serverKey}/tribes/{tribeId}/villages: + get: + operationId: listTribeVillages + tags: + - versions + - servers + - tribes + - villages + description: List tribe villages + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/TribeIdPathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + responses: + 200: + $ref: "#/components/responses/ListVillagesResponse" + default: + $ref: "#/components/responses/ErrorResponse" + /v2/versions/{versionCode}/servers/{serverKey}/players/{playerId}/villages: + get: + operationId: listPlayerVillages + tags: + - versions + - servers + - players + - villages + description: List player villages + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/PlayerIdPathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + responses: + 200: + $ref: "#/components/responses/ListVillagesResponse" + default: + $ref: "#/components/responses/ErrorResponse" components: schemas: Error: diff --git a/internal/adapter/repository_bun_village.go b/internal/adapter/repository_bun_village.go index 2fd09d9..d8eff04 100644 --- a/internal/adapter/repository_bun_village.go +++ b/internal/adapter/repository_bun_village.go @@ -164,6 +164,17 @@ func (a listVillagesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Where("(village.x, village.y) in (?)", bun.In(converted)) } + if playerIDs := a.params.PlayerIDs(); len(playerIDs) > 0 { + q = q.Where("village.player_id IN (?)", bun.In(playerIDs)) + } + + if tribeIDs := a.params.TribeIDs(); len(tribeIDs) > 0 { + q = q.Relation("Player.Tribe", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.ExcludeColumn("*") + }). + Where("player__tribe.id IN (?)", bun.In(tribeIDs)) + } + for _, s := range a.params.Sort() { switch s { case domain.VillageSortIDASC: diff --git a/internal/adapter/repository_village_test.go b/internal/adapter/repository_village_test.go index 60e6aec..e86fbe0 100644 --- a/internal/adapter/repository_village_test.go +++ b/internal/adapter/repository_village_test.go @@ -101,7 +101,13 @@ func testVillageRepository(t *testing.T, newRepos func(t *testing.T) repositorie name string params func(t *testing.T) domain.ListVillagesParams assertResult func(t *testing.T, params domain.ListVillagesParams, res domain.ListVillagesResult) - assertError func(t *testing.T, err error) + // assertResultWithRelations is optional + assertResultWithRelations func( + t *testing.T, + params domain.ListVillagesParams, + res domain.ListVillagesWithRelationsResult, + ) + assertError func(t *testing.T, err error) }{ { name: "OK: default params", @@ -221,6 +227,97 @@ func testVillageRepository(t *testing.T, newRepos func(t *testing.T) repositorie require.NoError(t, err) }, }, + { + name: "OK: playerIDs serverKeys", + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + + params := domain.NewListVillagesParams() + + res, err := repos.village.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.Villages()) + + var randVillage domain.Village + for _, v := range res.Villages() { + if v.PlayerID() > 0 { + randVillage = v + break + } + } + + require.NoError(t, params.SetPlayerIDs([]int{randVillage.PlayerID()})) + require.NoError(t, params.SetServerKeys([]string{randVillage.ServerKey()})) + + return params + }, + assertResult: func(t *testing.T, params domain.ListVillagesParams, res domain.ListVillagesResult) { + t.Helper() + + playerIDs := params.PlayerIDs() + serverKeys := params.ServerKeys() + + villages := res.Villages() + assert.NotEmpty(t, villages) + for _, v := range villages { + assert.True(t, slices.Contains(playerIDs, v.PlayerID())) + assert.True(t, slices.Contains(serverKeys, v.ServerKey())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: tribeIDs serverKeys", + params: func(t *testing.T) domain.ListVillagesParams { + t.Helper() + + params := domain.NewListVillagesParams() + + res, err := repos.village.ListWithRelations(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.Villages()) + + var randVillage domain.VillageWithRelations + for _, v := range res.Villages() { + if v.Player().V.Tribe().V.ID() > 0 { + randVillage = v + } + } + + require.NoError(t, params.SetTribeIDs([]int{randVillage.Player().V.Tribe().V.ID()})) + require.NoError(t, params.SetServerKeys([]string{randVillage.Village().ServerKey()})) + + return params + }, + assertResult: func(t *testing.T, _ domain.ListVillagesParams, res domain.ListVillagesResult) { + t.Helper() + assert.NotEmpty(t, res.Villages()) + }, + assertResultWithRelations: func( + t *testing.T, + params domain.ListVillagesParams, + res domain.ListVillagesWithRelationsResult, + ) { + t.Helper() + + tribeIDs := params.TribeIDs() + serverKeys := params.ServerKeys() + + villages := res.Villages() + assert.NotEmpty(t, villages) + for _, v := range villages { + assert.True(t, slices.Contains(tribeIDs, v.Player().V.Tribe().V.ID())) + assert.True(t, slices.Contains(serverKeys, v.Village().ServerKey())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, { name: "OK: cursor serverKeys sort=[id ASC]", params: func(t *testing.T) domain.ListVillagesParams { @@ -383,6 +480,9 @@ func testVillageRepository(t *testing.T, newRepos func(t *testing.T) repositorie assert.Equal(t, v.Village().PlayerID(), v.Player().V.Player().ID()) assert.Equal(t, v.Village().PlayerID() != 0, v.Player().Valid) } + if tt.assertResultWithRelations != nil { + tt.assertResultWithRelations(t, params, resWithRelations) + } }) } }) diff --git a/internal/domain/village.go b/internal/domain/village.go index bd3d78b..5f356a1 100644 --- a/internal/domain/village.go +++ b/internal/domain/village.go @@ -378,6 +378,8 @@ type ListVillagesParams struct { ids []int serverKeys []string coords []Coords + playerIDs []int + tribeIDs []int sort []VillageSort cursor VillageCursor limit int @@ -493,6 +495,48 @@ func (params *ListVillagesParams) SetCoordsString(rawCoords []string) error { return nil } +func (params *ListVillagesParams) PlayerIDs() []int { + return params.playerIDs +} + +func (params *ListVillagesParams) SetPlayerIDs(playerIDs []int) error { + for i, id := range playerIDs { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listVillagesParamsModelName, + Field: "playerIDs", + Index: i, + Err: err, + } + } + } + + params.playerIDs = playerIDs + + return nil +} + +func (params *ListVillagesParams) TribeIDs() []int { + return params.tribeIDs +} + +func (params *ListVillagesParams) SetTribeIDs(tribeIDs []int) error { + for i, id := range tribeIDs { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listVillagesParamsModelName, + Field: "tribeIDs", + Index: i, + Err: err, + } + } + } + + params.tribeIDs = tribeIDs + + return nil +} + func (params *ListVillagesParams) Sort() []VillageSort { return params.sort } diff --git a/internal/domain/village_test.go b/internal/domain/village_test.go index d5e4186..6d9ef4f 100644 --- a/internal/domain/village_test.go +++ b/internal/domain/village_test.go @@ -508,6 +508,126 @@ func TestListVillagesParams_SetCoordsString(t *testing.T) { } } +func TestListVillagesParams_SetPlayerIDs(t *testing.T) { + t.Parallel() + + type args struct { + playerIDs []int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + playerIDs: []int{ + domaintest.RandID(), + domaintest.RandID(), + domaintest.RandID(), + }, + }, + }, + { + name: "ERR: value < 1", + args: args{ + playerIDs: []int{ + domaintest.RandID(), + domaintest.RandID(), + domaintest.RandID(), + 0, + domaintest.RandID(), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListVillagesParams", + Field: "playerIDs", + Index: 3, + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetPlayerIDs(tt.args.playerIDs), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.playerIDs, params.PlayerIDs()) + }) + } +} + +func TestListVillagesParams_SetTribeIDs(t *testing.T) { + t.Parallel() + + type args struct { + tribeIDs []int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + tribeIDs: []int{ + domaintest.RandID(), + domaintest.RandID(), + domaintest.RandID(), + }, + }, + }, + { + name: "ERR: value < 1", + args: args{ + tribeIDs: []int{ + domaintest.RandID(), + domaintest.RandID(), + domaintest.RandID(), + 0, + domaintest.RandID(), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListVillagesParams", + Field: "tribeIDs", + Index: 3, + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetTribeIDs(tt.args.tribeIDs), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.tribeIDs, params.TribeIDs()) + }) + } +} + func TestListVillagesParams_SetSort(t *testing.T) { t.Parallel() diff --git a/internal/port/handler_http_api_village.go b/internal/port/handler_http_api_village.go index 1fd0140..fb9487f 100644 --- a/internal/port/handler_http_api_village.go +++ b/internal/port/handler_http_api_village.go @@ -60,6 +60,102 @@ func (h *apiHTTPHandler) ListVillages( renderJSON(w, r, http.StatusOK, apimodel.NewListVillagesResponse(res)) } +func (h *apiHTTPHandler) ListPlayerVillages( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + serverKey apimodel.ServerKeyPathParam, + playerID apimodel.PlayerIdPathParam, + params apimodel.ListPlayerVillagesParams, +) { + domainParams := domain.NewListVillagesParams() + + if err := domainParams.SetSort([]domain.VillageSort{domain.VillageSortIDASC}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetPlayerIDs([]int{playerID}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + } + + res, err := h.villageSvc.ListWithRelations(r.Context(), domainParams) + if err != nil { + h.errorRenderer.render(w, r, err) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListVillagesResponse(res)) +} + +func (h *apiHTTPHandler) ListTribeVillages( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + serverKey apimodel.ServerKeyPathParam, + tribeID apimodel.TribeIdPathParam, + params apimodel.ListTribeVillagesParams, +) { + domainParams := domain.NewListVillagesParams() + + if err := domainParams.SetSort([]domain.VillageSort{domain.VillageSortIDASC}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetTribeIDs([]int{tribeID}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + } + + res, err := h.villageSvc.ListWithRelations(r.Context(), domainParams) + if err != nil { + h.errorRenderer.render(w, r, err) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListVillagesResponse(res)) +} + func (h *apiHTTPHandler) GetVillage( w http.ResponseWriter, r *http.Request, diff --git a/internal/port/handler_http_api_village_test.go b/internal/port/handler_http_api_village_test.go index d208281..65732f6 100644 --- a/internal/port/handler_http_api_village_test.go +++ b/internal/port/handler_http_api_village_test.go @@ -490,6 +490,832 @@ func TestListVillages(t *testing.T) { } } +const endpointListPlayerVillages = "/v2/versions/%s/servers/%s/players/%d/villages" + +func TestListPlayerVillages(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + villages := getAllVillages(t, handler) + + var server serverWithVersion + var player apimodel.PlayerMeta + for _, v := range villages { + if v.Player != nil { + player = *v.Player + server = v.Server + break + } + } + + 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.ListVillagesResponse](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.Village) int { + return 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.ListVillagesResponse](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.ListVillagesResponse](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.ListVillagesResponse](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: "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: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "limit"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: limit > 500", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", "501") + 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: 500, + Current: limit, + } + 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), + }, + 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: 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: 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: domainErr.Code(), + Message: domainErr.Error(), + Path: []string{"$query", "cursor"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: version not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListPlayerVillages, domaintest.RandVersionCode(), 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: 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(endpointListPlayerVillages, 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: 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(endpointListPlayerVillages, 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{ + ServerKey: pathSegments[5], + ID: id, + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "serverKey": domainErr.ServerKey, + "id": float64(id), + }, + }, + }, + }, body) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf(endpointListPlayerVillages, 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) + }) + } +} + +const endpointListTribeVillages = "/v2/versions/%s/servers/%s/tribes/%d/villages" + +func TestListTribeVillages(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + villages := getAllVillages(t, handler) + + var server serverWithVersion + var tribe apimodel.TribeMeta + for _, v := range villages { + if v.Player != nil && v.Player.Tribe != nil { + tribe = *v.Player.Tribe + server = v.Server + break + } + } + + 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.ListVillagesResponse](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.Village) int { + return 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.ListVillagesResponse](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.ListVillagesResponse](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.ListVillagesResponse](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: "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: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "limit"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: limit > 500", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", "501") + 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: 500, + Current: limit, + } + 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), + }, + 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: 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: 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: domainErr.Code(), + Message: domainErr.Error(), + Path: []string{"$query", "cursor"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: version not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListTribeVillages, domaintest.RandVersionCode(), server.Key, tribe.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: 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(endpointListTribeVillages, server.Version.Code, domaintest.RandServerKey(), tribe.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: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "key": domainErr.Key, + }, + }, + }, + }, body) + }, + }, + { + name: "ERR: tribe not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListTribeVillages, 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.TribeNotFoundError{ + ServerKey: pathSegments[5], + ID: id, + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "serverKey": domainErr.ServerKey, + "id": float64(id), + }, + }, + }, + }, body) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf(endpointListTribeVillages, server.Version.Code, server.Key, tribe.Id), + nil, + ) + if tt.reqModifier != nil { + tt.reqModifier(t, req) + } + + resp := doCustomRequest(handler, req) + defer resp.Body.Close() + tt.assertResp(t, req, resp) + }) + } +} + const endpointGetVillage = "/v2/versions/%s/servers/%s/villages/%d" func TestGetVillage(t *testing.T) {