From db528e125e2eed19c46e32e7505b26ba1d171ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Mon, 11 Mar 2024 09:05:29 +0000 Subject: [PATCH] feat: api - /api/v2/versions/{versionCode}/servers/{serverKey}/ennoblements - new query params (#23) Reviewed-on: https://gitea.dwysokinski.me/twhelp/corev3/pulls/23 --- api/openapi3.yml | 31 ++ .../adapter/repository_bun_ennoblement.go | 26 ++ .../adapter/repository_ennoblement_test.go | 178 ++++++++-- internal/domain/ennoblement.go | 135 ++++++++ internal/domain/ennoblement_test.go | 317 ++++++++++++++++++ internal/domain/error.go | 2 +- internal/port/handler_http_api_ennoblement.go | 47 ++- .../port/handler_http_api_ennoblement_test.go | 275 +++++++++++++++ 8 files changed, 981 insertions(+), 30 deletions(-) diff --git a/api/openapi3.yml b/api/openapi3.yml index c165743..e2776dd 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -317,6 +317,9 @@ paths: - $ref: "#/components/parameters/ServerKeyPathParam" - $ref: "#/components/parameters/CursorQueryParam" - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/EnnoblementSortQueryParam" + - $ref: "#/components/parameters/SinceQueryParam" + - $ref: "#/components/parameters/BeforeQueryParam" responses: 200: $ref: "#/components/responses/ListEnnoblementsResponse" @@ -1426,6 +1429,34 @@ components: type: string example: 500|500 maxItems: 200 + EnnoblementSortQueryParam: + name: sort + in: query + description: Order matters! + schema: + type: array + default: + - createdAt:ASC + items: + type: string + enum: + - createdAt:ASC + - createdAt:DESC + maxItems: 1 + SinceQueryParam: + name: since + in: query + description: only items created since the provided time are returned, this is a timestamp in RFC 3339 format + schema: + type: string + format: date-time + BeforeQueryParam: + name: before + in: query + description: only items created before the provided time are returned, this is a timestamp in RFC 3339 format + schema: + type: string + format: date-time VersionCodePathParam: in: path name: versionCode diff --git a/internal/adapter/repository_bun_ennoblement.go b/internal/adapter/repository_bun_ennoblement.go index 8bb2a96..60c3331 100644 --- a/internal/adapter/repository_bun_ennoblement.go +++ b/internal/adapter/repository_bun_ennoblement.go @@ -118,6 +118,32 @@ func (a listEnnoblementsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer q = q.Where("ennoblement.server_key IN (?)", bun.In(serverKeys)) } + if villageIDs := a.params.PlayerIDs(); len(villageIDs) > 0 { + q = q.Where("ennoblement.village_id IN (?)", bun.In(villageIDs)) + } + + if playerIDs := a.params.PlayerIDs(); len(playerIDs) > 0 { + q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("ennoblement.new_owner_id IN (?)", bun.In(playerIDs)). + WhereOr("ennoblement.old_owner_id IN (?)", bun.In(playerIDs)) + }) + } + + if tribeIDs := a.params.TribeIDs(); len(tribeIDs) > 0 { + q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("ennoblement.new_tribe_id IN (?)", bun.In(tribeIDs)). + WhereOr("ennoblement.old_tribe_id IN (?)", bun.In(tribeIDs)) + }) + } + + if since := a.params.Since(); since.Valid { + q = q.Where("ennoblement.created_at >= ?", since.V) + } + + if before := a.params.Before(); before.Valid { + q = q.Where("ennoblement.created_at < ?", before.V) + } + for _, s := range a.params.Sort() { switch s { case domain.EnnoblementSortCreatedAtASC: diff --git a/internal/adapter/repository_ennoblement_test.go b/internal/adapter/repository_ennoblement_test.go index aa3060c..e45058d 100644 --- a/internal/adapter/repository_ennoblement_test.go +++ b/internal/adapter/repository_ennoblement_test.go @@ -128,7 +128,6 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit params func(t *testing.T) domain.ListEnnoblementsParams assertResult func(t *testing.T, params domain.ListEnnoblementsParams, res domain.ListEnnoblementsResult) assertError func(t *testing.T, err error) - assertTotal func(t *testing.T, params domain.ListEnnoblementsParams, total int) }{ { name: "OK: default params", @@ -154,10 +153,6 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListEnnoblementsParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: sort=[serverKey DESC, createdAt DESC, id ASC]", @@ -187,10 +182,6 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListEnnoblementsParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: sort=[id ASC]", @@ -214,10 +205,6 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListEnnoblementsParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: sort=[id DESC]", @@ -241,10 +228,6 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListEnnoblementsParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, { name: "OK: serverKeys", @@ -277,9 +260,162 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListEnnoblementsParams, total int) { + }, + { + name: "OK: villageIDs serverKeys", + params: func(t *testing.T) domain.ListEnnoblementsParams { t.Helper() - assert.NotEmpty(t, total) + + params := domain.NewListEnnoblementsParams() + + res, err := repos.ennoblement.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.Ennoblements()) + randEnnoblement := res.Ennoblements()[0] + + require.NoError(t, params.SetServerKeys([]string{randEnnoblement.ServerKey()})) + require.NoError(t, params.SetVillageIDs([]int{randEnnoblement.VillageID()})) + + return params + }, + assertResult: func(t *testing.T, params domain.ListEnnoblementsParams, res domain.ListEnnoblementsResult) { + t.Helper() + + serverKeys := params.ServerKeys() + villageIDs := params.VillageIDs() + + ennoblements := res.Ennoblements() + assert.NotZero(t, ennoblements) + for _, e := range ennoblements { + assert.True(t, slices.Contains(serverKeys, e.ServerKey())) + assert.True(t, slices.Contains(villageIDs, e.VillageID())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: playerIDs (new owner) serverKeys", + params: func(t *testing.T) domain.ListEnnoblementsParams { + t.Helper() + + params := domain.NewListEnnoblementsParams() + + res, err := repos.ennoblement.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.Ennoblements()) + randEnnoblement := res.Ennoblements()[0] + + require.NoError(t, params.SetServerKeys([]string{randEnnoblement.ServerKey()})) + require.NoError(t, params.SetPlayerIDs([]int{randEnnoblement.NewOwnerID()})) + + return params + }, + assertResult: func(t *testing.T, params domain.ListEnnoblementsParams, res domain.ListEnnoblementsResult) { + t.Helper() + + serverKeys := params.ServerKeys() + playerIDs := params.PlayerIDs() + + ennoblements := res.Ennoblements() + assert.NotZero(t, ennoblements) + for _, e := range ennoblements { + assert.True(t, slices.Contains(serverKeys, e.ServerKey())) + assert.True(t, slices.Contains(playerIDs, e.NewOwnerID()) || slices.Contains(playerIDs, e.OldOwnerID())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: playerIDs (old owner) serverKeys", + params: func(t *testing.T) domain.ListEnnoblementsParams { + t.Helper() + + params := domain.NewListEnnoblementsParams() + + res, err := repos.ennoblement.List(ctx, params) + require.NoError(t, err) + require.NotEmpty(t, res.Ennoblements()) + + var randEnnoblement domain.Ennoblement + for _, e := range res.Ennoblements() { + if e.OldOwnerID() > 0 { + randEnnoblement = e + break + } + } + + require.NoError(t, params.SetServerKeys([]string{randEnnoblement.ServerKey()})) + require.NoError(t, params.SetPlayerIDs([]int{randEnnoblement.OldOwnerID()})) + + return params + }, + assertResult: func(t *testing.T, params domain.ListEnnoblementsParams, res domain.ListEnnoblementsResult) { + t.Helper() + + serverKeys := params.ServerKeys() + playerIDs := params.PlayerIDs() + + ennoblements := res.Ennoblements() + assert.NotZero(t, ennoblements) + for _, e := range ennoblements { + assert.True(t, slices.Contains(serverKeys, e.ServerKey())) + assert.True(t, slices.Contains(playerIDs, e.NewOwnerID()) || slices.Contains(playerIDs, e.OldOwnerID())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: since before", + params: func(t *testing.T) domain.ListEnnoblementsParams { + t.Helper() + + params := domain.NewListEnnoblementsParams() + require.NoError(t, params.SetSort([]domain.EnnoblementSort{ + domain.EnnoblementSortCreatedAtASC, + domain.EnnoblementSortIDASC, + })) + + res, err := repos.ennoblement.List(ctx, params) + require.NoError(t, err) + require.GreaterOrEqual(t, len(res.Ennoblements()), 4) + + params = domain.NewListEnnoblementsParams() + require.NoError(t, params.SetSince(domain.NullTime{ + V: res.Ennoblements()[1].CreatedAt(), + Valid: true, + })) + require.NoError(t, params.SetBefore(domain.NullTime{ + V: res.Ennoblements()[3].CreatedAt(), + Valid: true, + })) + + return params + }, + assertResult: func(t *testing.T, params domain.ListEnnoblementsParams, res domain.ListEnnoblementsResult) { + t.Helper() + + since := params.Since().V + before := params.Before().V + + ennoblements := res.Ennoblements() + assert.NotZero(t, ennoblements) + for _, e := range ennoblements { + assert.True(t, e.CreatedAt().After(since) || e.CreatedAt().Equal(since)) + assert.True(t, e.CreatedAt().Before(before)) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) }, }, { @@ -432,10 +568,6 @@ func testEnnoblementRepository(t *testing.T, newRepos func(t *testing.T) reposit t.Helper() require.NoError(t, err) }, - assertTotal: func(t *testing.T, _ domain.ListEnnoblementsParams, total int) { - t.Helper() - assert.NotEmpty(t, total) - }, }, } diff --git a/internal/domain/ennoblement.go b/internal/domain/ennoblement.go index cd57a38..ecb572a 100644 --- a/internal/domain/ennoblement.go +++ b/internal/domain/ennoblement.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "slices" + "strings" "time" ) @@ -230,6 +231,23 @@ const ( EnnoblementSortServerKeyDESC ) +func newEnnoblementSortFromString(s string) (EnnoblementSort, error) { + allowed := []EnnoblementSort{ + EnnoblementSortCreatedAtASC, + EnnoblementSortCreatedAtDESC, + } + + for _, a := range allowed { + if strings.EqualFold(a.String(), s) { + return a, nil + } + } + + return 0, UnsupportedSortStringError{ + Sort: s, + } +} + // IsInConflict returns true if two sorts can't be used together (e.g. EnnoblementSortIDASC and EnnoblementSortIDDESC). func (s EnnoblementSort) IsInConflict(s2 EnnoblementSort) bool { ss := []EnnoblementSort{s, s2} @@ -354,6 +372,11 @@ func (ec EnnoblementCursor) Encode() string { type ListEnnoblementsParams struct { serverKeys []string + villageIDs []int + playerIDs []int // Ennoblement.NewOwnerID or Ennoblement.OldOwnerID + tribeIDs []int // Ennoblement.NewTribeID or Ennoblement.OldTribeID + since NullTime + before NullTime sort []EnnoblementSort cursor EnnoblementCursor limit int @@ -396,6 +419,87 @@ func (params *ListEnnoblementsParams) SetServerKeys(serverKeys []string) error { return nil } +func (params *ListEnnoblementsParams) VillageIDs() []int { + return params.villageIDs +} + +func (params *ListEnnoblementsParams) SetVillageIDs(villageIDs []int) error { + for i, id := range villageIDs { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listEnnoblementsParamsModelName, + Field: "villageIDs", + Index: i, + Err: err, + } + } + } + + params.villageIDs = villageIDs + + return nil +} + +func (params *ListEnnoblementsParams) PlayerIDs() []int { + return params.playerIDs +} + +func (params *ListEnnoblementsParams) SetPlayerIDs(playerIDs []int) error { + for i, id := range playerIDs { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listEnnoblementsParamsModelName, + Field: "playerIDs", + Index: i, + Err: err, + } + } + } + + params.playerIDs = playerIDs + + return nil +} + +func (params *ListEnnoblementsParams) TribeIDs() []int { + return params.tribeIDs +} + +func (params *ListEnnoblementsParams) SetTribeIDs(tribeIDs []int) error { + for i, id := range tribeIDs { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listEnnoblementsParamsModelName, + Field: "tribeIDs", + Index: i, + Err: err, + } + } + } + + params.tribeIDs = tribeIDs + + return nil +} + +func (params *ListEnnoblementsParams) Since() NullTime { + return params.since +} + +func (params *ListEnnoblementsParams) SetSince(since NullTime) error { + params.since = since + return nil +} + +func (params *ListEnnoblementsParams) Before() NullTime { + return params.before +} + +func (params *ListEnnoblementsParams) SetBefore(before NullTime) error { + params.before = before + return nil +} + func (params *ListEnnoblementsParams) Sort() []EnnoblementSort { return params.sort } @@ -419,6 +523,37 @@ func (params *ListEnnoblementsParams) SetSort(sort []EnnoblementSort) error { return nil } +func (params *ListEnnoblementsParams) PrependSortString(sort []string) error { + if err := validateSliceLen( + sort, + ennoblementSortMinLength, + max(ennoblementSortMaxLength-len(params.sort), 0), + ); err != nil { + return ValidationError{ + Model: listEnnoblementsParamsModelName, + Field: "sort", + Err: err, + } + } + + toPrepend := make([]EnnoblementSort, 0, len(sort)) + + for i, s := range sort { + converted, err := newEnnoblementSortFromString(s) + if err != nil { + return SliceElementValidationError{ + Model: listEnnoblementsParamsModelName, + Field: "sort", + Index: i, + Err: err, + } + } + toPrepend = append(toPrepend, converted) + } + + return params.SetSort(append(toPrepend, params.sort...)) +} + func (params *ListEnnoblementsParams) Cursor() EnnoblementCursor { return params.cursor } diff --git a/internal/domain/ennoblement_test.go b/internal/domain/ennoblement_test.go index 7c7c1bf..23c2c6f 100644 --- a/internal/domain/ennoblement_test.go +++ b/internal/domain/ennoblement_test.go @@ -239,6 +239,186 @@ func TestListEnnoblementsParams_SetServerKeys(t *testing.T) { } } +func TestListEnnoblementsParams_SetVillageIDs(t *testing.T) { + t.Parallel() + + type args struct { + villageIDs []int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + villageIDs: []int{ + domaintest.RandID(), + domaintest.RandID(), + domaintest.RandID(), + }, + }, + }, + { + name: "ERR: value < 1", + args: args{ + villageIDs: []int{ + domaintest.RandID(), + domaintest.RandID(), + domaintest.RandID(), + 0, + domaintest.RandID(), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListEnnoblementsParams", + Field: "villageIDs", + 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.NewListEnnoblementsParams() + + require.ErrorIs(t, params.SetVillageIDs(tt.args.villageIDs), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.villageIDs, params.VillageIDs()) + }) + } +} + +func TestListEnnoblementsParams_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: "ListEnnoblementsParams", + 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.NewListEnnoblementsParams() + + require.ErrorIs(t, params.SetPlayerIDs(tt.args.playerIDs), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.playerIDs, params.PlayerIDs()) + }) + } +} + +func TestListEnnoblementsParams_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: "ListEnnoblementsParams", + 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.NewListEnnoblementsParams() + + require.ErrorIs(t, params.SetTribeIDs(tt.args.tribeIDs), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.tribeIDs, params.TribeIDs()) + }) + } +} + func TestListEnnoblementsParams_SetSort(t *testing.T) { t.Parallel() @@ -329,6 +509,143 @@ func TestListEnnoblementsParams_SetSort(t *testing.T) { } } +func TestListEnnoblementsParams_PrependSortString(t *testing.T) { + t.Parallel() + + defaultNewParams := func(t *testing.T) domain.ListEnnoblementsParams { + t.Helper() + return domain.ListEnnoblementsParams{} + } + + type args struct { + sort []string + } + + tests := []struct { + name string + newParams func(t *testing.T) domain.ListEnnoblementsParams + args args + expectedSort []domain.EnnoblementSort + expectedErr error + }{ + { + name: "OK: [createdAt:ASC]", + args: args{ + sort: []string{ + "createdAt:ASC", + }, + }, + expectedSort: []domain.EnnoblementSort{ + domain.EnnoblementSortCreatedAtASC, + }, + }, + { + name: "OK: [createdAt:DESC]", + args: args{ + sort: []string{ + "createdAt:DESC", + }, + }, + expectedSort: []domain.EnnoblementSort{ + domain.EnnoblementSortCreatedAtDESC, + }, + }, + { + name: "ERR: len(sort) < 1", + args: args{ + sort: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListEnnoblementsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 3, + Current: 0, + }, + }, + }, + { + name: "ERR: custom params + len(sort) > 1", + newParams: func(t *testing.T) domain.ListEnnoblementsParams { + t.Helper() + params := domain.NewListEnnoblementsParams() + require.NoError(t, params.SetSort([]domain.EnnoblementSort{ + domain.EnnoblementSortServerKeyASC, + domain.EnnoblementSortIDASC, + })) + return params + }, + args: args{ + sort: []string{ + "createdAt:ASC", + "createdAt:DESC", + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListEnnoblementsParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 1, + Current: 2, + }, + }, + }, + { + name: "ERR: unsupported sort string", + newParams: defaultNewParams, + args: args{ + sort: []string{ + "createdAt:", + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListEnnoblementsParams", + Field: "sort", + Index: 0, + Err: domain.UnsupportedSortStringError{ + Sort: "createdAt:", + }, + }, + }, + { + name: "ERR: conflict", + args: args{ + sort: []string{ + "createdAt:ASC", + "createdAt:DESC", + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListEnnoblementsParams", + Field: "sort", + Err: domain.SortConflictError{ + Sort: [2]string{domain.EnnoblementSortCreatedAtASC.String(), domain.EnnoblementSortCreatedAtDESC.String()}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + newParams := defaultNewParams + if tt.newParams != nil { + newParams = tt.newParams + } + params := newParams(t) + + require.ErrorIs(t, params.PrependSortString(tt.args.sort), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.expectedSort, params.Sort()) + }) + } +} + func TestListEnnoblementsParams_SetEncodedCursor(t *testing.T) { t.Parallel() diff --git a/internal/domain/error.go b/internal/domain/error.go index f27a614..77bd20c 100644 --- a/internal/domain/error.go +++ b/internal/domain/error.go @@ -48,7 +48,7 @@ func (s ErrorPathSegment) String() string { path += "." } path += s.Field - if s.Index > 0 { + if s.Index >= 0 { path += "[" + strconv.Itoa(s.Index) + "]" } } diff --git a/internal/port/handler_http_api_ennoblement.go b/internal/port/handler_http_api_ennoblement.go index 9f58896..fdebe78 100644 --- a/internal/port/handler_http_api_ennoblement.go +++ b/internal/port/handler_http_api_ennoblement.go @@ -1,13 +1,14 @@ package port import ( - "fmt" "net/http" + "strconv" "gitea.dwysokinski.me/twhelp/corev3/internal/domain" "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" ) +//nolint:gocyclo func (h *apiHTTPHandler) ListEnnoblements( w http.ResponseWriter, r *http.Request, @@ -17,19 +18,48 @@ func (h *apiHTTPHandler) ListEnnoblements( ) { domainParams := domain.NewListEnnoblementsParams() - if err := domainParams.SetSort([]domain.EnnoblementSort{ - domain.EnnoblementSortCreatedAtASC, - domain.EnnoblementSortIDASC, - }); err != nil { + if err := domainParams.SetSort([]domain.EnnoblementSort{domain.EnnoblementSortIDASC}); err != nil { h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) return } + if params.Sort != nil { + if err := domainParams.PrependSortString(*params.Sort); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) + return + } + } else { + if err := domainParams.PrependSortString([]string{domain.EnnoblementSortCreatedAtASC.String()}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) + return + } + } + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) return } + if params.Before != nil { + if err := domainParams.SetBefore(domain.NullTime{ + V: *params.Before, + Valid: true, + }); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) + return + } + } + + if params.Since != nil { + if err := domainParams.SetSince(domain.NullTime{ + V: *params.Since, + Valid: true, + }); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) + return + } + } + if params.Limit != nil { if err := domainParams.SetLimit(*params.Limit); err != nil { h.errorRenderer.withErrorPathFormatter(formatListEnnoblementsErrorPath).render(w, r, err) @@ -46,7 +76,6 @@ func (h *apiHTTPHandler) ListEnnoblements( res, err := h.ennoblementSvc.ListWithRelations(r.Context(), domainParams) if err != nil { - fmt.Println(err) h.errorRenderer.render(w, r, err) return } @@ -64,6 +93,12 @@ func formatListEnnoblementsErrorPath(segments []domain.ErrorPathSegment) []strin 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 } diff --git a/internal/port/handler_http_api_ennoblement_test.go b/internal/port/handler_http_api_ennoblement_test.go index 9d0e494..dc21095 100644 --- a/internal/port/handler_http_api_ennoblement_test.go +++ b/internal/port/handler_http_api_ennoblement_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "gitea.dwysokinski.me/twhelp/corev3/internal/domain" "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" @@ -105,6 +106,112 @@ func TestListEnnoblements(t *testing.T) { assert.Len(t, body.Data, limit) }, }, + { + name: "OK: sort=[createdAt:DESC]", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("sort", "createdAt: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.ListPlayersResponse](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.Player) int { + return cmp.Or( + a.CreatedAt.Compare(b.CreatedAt)*-1, + cmp.Compare(a.Id, b.Id), + ) + })) + }, + }, + { + name: "OK: since", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + var filtered []ennoblementWithServer + for _, e := range ennoblements { + if e.Server.Key == server.Key { + filtered = append(filtered, e) + } + } + slices.SortFunc(filtered, func(a, b ennoblementWithServer) int { + return cmp.Or( + a.CreatedAt.Compare(b.CreatedAt), + cmp.Compare(a.Id, b.Id), + ) + }) + require.GreaterOrEqual(t, len(filtered), 2) + + q := req.URL.Query() + q.Set("since", filtered[1].CreatedAt.Format(time.RFC3339)) + 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.ListEnnoblementsResponse](t, resp.Body) + assert.Zero(t, body.Cursor.Next) + assert.NotZero(t, body.Cursor.Self) + assert.NotZero(t, body.Data) + since, err := time.Parse(time.RFC3339, req.URL.Query().Get("since")) + require.NoError(t, err) + for _, e := range body.Data { + assert.True(t, e.CreatedAt.After(since) || e.CreatedAt.Equal(since)) + } + }, + }, + { + name: "OK: after", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + var filtered []ennoblementWithServer + for _, e := range ennoblements { + if e.Server.Key == server.Key { + filtered = append(filtered, e) + } + } + slices.SortFunc(filtered, func(a, b ennoblementWithServer) int { + return cmp.Or( + a.CreatedAt.Compare(b.CreatedAt), + cmp.Compare(a.Id, b.Id), + ) + }) + require.GreaterOrEqual(t, len(filtered), 2) + + q := req.URL.Query() + q.Set("before", filtered[1].CreatedAt.Format(time.RFC3339)) + 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.ListEnnoblementsResponse](t, resp.Body) + assert.Zero(t, body.Cursor.Next) + assert.NotZero(t, body.Cursor.Self) + assert.NotZero(t, body.Data) + before, err := time.Parse(time.RFC3339, req.URL.Query().Get("before")) + require.NoError(t, err) + for _, e := range body.Data { + assert.True(t, e.CreatedAt.Before(before)) + } + }, + }, { name: "ERR: limit is not a string", reqModifier: func(t *testing.T, req *http.Request) { @@ -306,6 +413,174 @@ func TestListEnnoblements(t *testing.T) { }, body) }, }, + { + name: "ERR: len(sort) > 2", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Add("sort", "createdAt:DESC") + q.Add("sort", "createdAt:ASC") + q.Add("sort", "createdAt: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: 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", "createdAt:") + 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: 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", "createdAt:DESC") + q.Add("sort", "createdAt: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: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "sort": paramSort, + }, + Path: []string{"$query", "sort"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: invalid since", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("since", gofakeit.LetterN(100)) + 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) + var domainErr domain.Error + require.ErrorAs(t, domain.ErrInvalidCursor, &domainErr) + since := req.URL.Query().Get("since") + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: "invalid-param-format", + //nolint:lll + Message: fmt.Sprintf("error parsing '%s' as RFC3339 or 2006-01-02 time: parsing time \"%s\" as \"2006-01-02\": cannot parse \"%s\" as \"2006\"", since, since, since), + Path: []string{"$query", "since"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: invalid before", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("before", gofakeit.LetterN(100)) + 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) + var domainErr domain.Error + require.ErrorAs(t, domain.ErrInvalidCursor, &domainErr) + before := req.URL.Query().Get("before") + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: "invalid-param-format", + //nolint:lll + Message: fmt.Sprintf("error parsing '%s' as RFC3339 or 2006-01-02 time: parsing time \"%s\" as \"2006-01-02\": cannot parse \"%s\" as \"2006\"", before, before, before), + Path: []string{"$query", "before"}, + }, + }, + }, body) + }, + }, { name: "ERR: version not found", reqModifier: func(t *testing.T, req *http.Request) {