diff --git a/api/openapi3.yml b/api/openapi3.yml index 47d01fa..435e855 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -240,6 +240,7 @@ paths: - $ref: "#/components/parameters/ServerKeyPathParam" - $ref: "#/components/parameters/CursorQueryParam" - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/VillageCoordsQueryParam" responses: 200: $ref: "#/components/responses/ListVillagesResponse" @@ -1290,6 +1291,15 @@ components: minLength: 1 maxLength: 150 maxItems: 100 + VillageCoordsQueryParam: + name: coords + in: query + schema: + type: array + items: + type: string + example: 500|500 + maxItems: 200 VersionCodePathParam: in: path name: versionCode diff --git a/internal/adapter/repository_bun_village.go b/internal/adapter/repository_bun_village.go index 9d5544f..2fd09d9 100644 --- a/internal/adapter/repository_bun_village.go +++ b/internal/adapter/repository_bun_village.go @@ -155,6 +155,15 @@ func (a listVillagesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Where("village.server_key IN (?)", bun.In(serverKeys)) } + if coords := a.params.Coords(); len(coords) > 0 { + converted := make([][]int, 0, len(coords)) + for _, c := range coords { + converted = append(converted, []int{c.X(), c.Y()}) + } + + q = q.Where("(village.x, village.y) in (?)", bun.In(converted)) + } + 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 3e0c12a..60e6aec 100644 --- a/internal/adapter/repository_village_test.go +++ b/internal/adapter/repository_village_test.go @@ -186,6 +186,41 @@ func testVillageRepository(t *testing.T, newRepos func(t *testing.T) repositorie require.NoError(t, err) }, }, + { + name: "OK: coords 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()) + randVillage := res.Villages()[0] + + require.NoError(t, params.SetCoords([]domain.Coords{randVillage.Coords()})) + require.NoError(t, params.SetServerKeys([]string{randVillage.ServerKey()})) + + return params + }, + assertResult: func(t *testing.T, params domain.ListVillagesParams, res domain.ListVillagesResult) { + t.Helper() + + coords := params.Coords() + serverKeys := params.ServerKeys() + + villages := res.Villages() + assert.Len(t, villages, len(coords)) + for _, v := range villages { + assert.True(t, slices.Contains(coords, v.Coords())) + assert.True(t, slices.Contains(serverKeys, v.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 { diff --git a/internal/domain/coords.go b/internal/domain/coords.go new file mode 100644 index 0000000..e01ee4e --- /dev/null +++ b/internal/domain/coords.go @@ -0,0 +1,53 @@ +package domain + +import ( + "strconv" + "strings" +) + +type Coords struct { + x int + y int +} + +var ErrInvalidCoordsString error = simpleError{ + msg: "invalid coords string, should be x|y", + typ: ErrorTypeIncorrectInput, + code: "invalid-coords-string", +} + +const coordsSubstrings = 2 + +func newCoords(s string) (Coords, error) { + coords := strings.SplitN(s, "|", coordsSubstrings) + if len(coords) != coordsSubstrings { + return Coords{}, ErrInvalidCoordsString + } + + x, err := strconv.Atoi(coords[0]) + if err != nil || x <= 0 { + return Coords{}, ErrInvalidCoordsString + } + + y, err := strconv.Atoi(coords[1]) + if err != nil || y <= 0 { + return Coords{}, ErrInvalidCoordsString + } + + return Coords{ + x: x, + y: y, + }, nil +} + +func (c Coords) X() int { + return c.x +} + +func (c Coords) Y() int { + return c.y +} + +func (c Coords) String() string { + return strconv.Itoa(c.x) + "|" + strconv.Itoa(c.y) +} diff --git a/internal/domain/domaintest/village.go b/internal/domain/domaintest/village.go index 51ac823..52a61be 100644 --- a/internal/domain/domaintest/village.go +++ b/internal/domain/domaintest/village.go @@ -58,8 +58,8 @@ func NewVillage(tb TestingTB, opts ...func(cfg *VillageConfig)) domain.Village { cfg.ServerKey, gofakeit.LetterN(50), gofakeit.IntRange(1, 10000), - gofakeit.IntRange(1, 1000), - gofakeit.IntRange(1, 1000), + gofakeit.IntRange(1, 999), + gofakeit.IntRange(1, 999), gofakeit.LetterN(3), 0, cfg.PlayerID, diff --git a/internal/domain/village.go b/internal/domain/village.go index 6d5038f..ac16ae4 100644 --- a/internal/domain/village.go +++ b/internal/domain/village.go @@ -117,6 +117,13 @@ func (v Village) Y() int { return v.y } +func (v Village) Coords() Coords { + return Coords{ + x: v.x, + y: v.y, + } +} + func (v Village) Continent() string { return v.continent } @@ -370,6 +377,7 @@ func (vc VillageCursor) Encode() string { type ListVillagesParams struct { ids []int serverKeys []string + coords []Coords sort []VillageSort cursor VillageCursor limit int @@ -432,6 +440,59 @@ func (params *ListVillagesParams) SetServerKeys(serverKeys []string) error { return nil } +func (params *ListVillagesParams) Coords() []Coords { + return params.coords +} + +const ( + villageCoordsMinLength = 0 + villageCoordsMaxLength = 200 +) + +func (params *ListVillagesParams) SetCoords(coords []Coords) error { + if err := validateSliceLen(coords, villageCoordsMinLength, villageCoordsMaxLength); err != nil { + return ValidationError{ + Model: listVillagesParamsModelName, + Field: "coords", + Err: err, + } + } + + params.coords = coords + + return nil +} + +func (params *ListVillagesParams) SetCoordsString(rawCoords []string) error { + if err := validateSliceLen(rawCoords, villageCoordsMinLength, villageCoordsMaxLength); err != nil { + return ValidationError{ + Model: listVillagesParamsModelName, + Field: "coords", + Err: err, + } + } + + coords := make([]Coords, 0, len(rawCoords)) + + for i, s := range rawCoords { + c, err := newCoords(s) + if err != nil { + return SliceElementValidationError{ + Model: listVillagesParamsModelName, + Field: "coords", + Index: i, + Err: err, + } + } + + coords = append(coords, c) + } + + params.coords = coords + + 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 ecd98b2..d5e4186 100644 --- a/internal/domain/village_test.go +++ b/internal/domain/village_test.go @@ -337,6 +337,177 @@ func TestListVillagesParams_SetServerKeys(t *testing.T) { } } +func TestListVillagesParams_SetCoords(t *testing.T) { + t.Parallel() + + type args struct { + coords []domain.Coords + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + coords: []domain.Coords{ + domaintest.NewVillage(t).Coords(), + domaintest.NewVillage(t).Coords(), + domaintest.NewVillage(t).Coords(), + }, + }, + }, + { + name: "ERR: len(coords) > 200", + args: args{ + coords: func() []domain.Coords { + coords := make([]domain.Coords, 201) + for i := range coords { + coords[i] = domaintest.NewVillage(t).Coords() + } + return coords + }(), + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + Field: "coords", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 200, + Current: 201, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetCoords(tt.args.coords), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.coords, params.Coords()) + }) + } +} + +func TestListVillagesParams_SetCoordsString(t *testing.T) { + t.Parallel() + + type args struct { + coords []string + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + coords: []string{ + domaintest.NewVillage(t).Coords().String(), + domaintest.NewVillage(t).Coords().String(), + domaintest.NewVillage(t).Coords().String(), + }, + }, + }, + { + name: "ERR: len(coords) > 200", + args: args{ + coords: func() []string { + coords := make([]string, 201) + for i := range coords { + coords[i] = domaintest.NewVillage(t).Coords().String() + } + return coords + }(), + }, + expectedErr: domain.ValidationError{ + Model: "ListVillagesParams", + Field: "coords", + Err: domain.LenOutOfRangeError{ + Min: 0, + Max: 200, + Current: 201, + }, + }, + }, + { + name: "ERR: invalid coords string 1", + args: args{ + coords: []string{ + domaintest.NewVillage(t).Coords().String(), + domaintest.NewVillage(t).Coords().String(), + "xxx|xxx", + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListVillagesParams", + Field: "coords", + Index: 2, + Err: domain.ErrInvalidCoordsString, + }, + }, + { + name: "ERR: invalid coords string 2", + args: args{ + coords: []string{ + domaintest.NewVillage(t).Coords().String(), + domaintest.NewVillage(t).Coords().String(), + "xxx", + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListVillagesParams", + Field: "coords", + Index: 2, + Err: domain.ErrInvalidCoordsString, + }, + }, + { + name: "ERR: invalid coords string 3", + args: args{ + coords: []string{ + domaintest.NewVillage(t).Coords().String(), + domaintest.NewVillage(t).Coords().String(), + domaintest.NewVillage(t).Coords().String() + domaintest.NewVillage(t).Coords().String(), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListVillagesParams", + Field: "coords", + Index: 2, + Err: domain.ErrInvalidCoordsString, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListVillagesParams() + + require.ErrorIs(t, params.SetCoordsString(tt.args.coords), tt.expectedErr) + if tt.expectedErr != nil { + return + } + require.Len(t, params.Coords(), len(tt.args.coords)) + for i, c := range tt.args.coords { + assert.Equal(t, c, params.Coords()[i].String()) + } + }) + } +} + 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 915a43a..8c8bb23 100644 --- a/internal/port/handler_http_api_village.go +++ b/internal/port/handler_http_api_village.go @@ -2,6 +2,7 @@ package port import ( "net/http" + "strconv" "gitea.dwysokinski.me/twhelp/corev3/internal/domain" "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" @@ -26,6 +27,13 @@ func (h *apiHTTPHandler) ListVillages( return } + if params.Coords != nil { + if err := domainParams.SetCoordsString(*params.Coords); 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) @@ -59,6 +67,12 @@ func formatListVillagesErrorPath(segments []domain.ErrorPathSegment) []string { return []string{"$query", "cursor"} case "limit": return []string{"$query", "limit"} + case "coords": + path := []string{"$query", "coords"} + if segments[0].Index >= 0 { + path = append(path, strconv.Itoa(segments[0].Index)) + } + return path default: return nil } diff --git a/internal/port/handler_http_api_village_test.go b/internal/port/handler_http_api_village_test.go index 59d99f0..7a235b4 100644 --- a/internal/port/handler_http_api_village_test.go +++ b/internal/port/handler_http_api_village_test.go @@ -102,6 +102,43 @@ func TestListVillages(t *testing.T) { assert.Len(t, body.Data, limit) }, }, + { + name: "OK: coords", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + q := req.URL.Query() + + for _, v := range villages { + if v.Server.Key == server.Key { + q.Add("coords", fmt.Sprintf("%d|%d", v.X, v.Y)) + } + + if len(q["coords"]) == 2 { + break + } + } + + require.NotEmpty(t, q["coords"]) + + 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.Self) + assert.NotZero(t, body.Data) + coords := req.URL.Query()["coords"] + assert.Len(t, body.Data, len(coords)) + for _, v := range body.Data { + assert.True(t, slices.Contains(coords, fmt.Sprintf("%d|%d", v.X, v.Y))) + } + }, + }, { name: "ERR: limit is not a string", reqModifier: func(t *testing.T, req *http.Request) { @@ -303,6 +340,34 @@ func TestListVillages(t *testing.T) { }, body) }, }, + { + name: "ERR: invalid coords string", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("coords", 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.ErrInvalidCoordsString, &domainErr) + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Path: []string{"$query", "coords", "0"}, + }, + }, + }, body) + }, + }, { name: "ERR: version not found", reqModifier: func(t *testing.T, req *http.Request) {