From f15b17dee0df030279b3e58d9229ccbe1d40cbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Wed, 13 Mar 2024 07:23:22 +0000 Subject: [PATCH] feat: tribe change - add 2 new api endpoints (#27) Reviewed-on: https://gitea.dwysokinski.me/twhelp/corev3/pulls/27 --- api/openapi3.yml | 110 +- cmd/twhelp/cmd_serve.go | 5 +- internal/domain/tribe_change.go | 49 + internal/domain/tribe_change_test.go | 137 ++ internal/port/handler_http_api.go | 3 + .../port/handler_http_api_ennoblement_test.go | 8 +- internal/port/handler_http_api_test.go | 2 + .../port/handler_http_api_tribe_change.go | 192 +++ .../handler_http_api_tribe_change_test.go | 1488 +++++++++++++++++ .../port/internal/apimodel/tribe_change.go | 33 + 10 files changed, 2013 insertions(+), 14 deletions(-) create mode 100644 internal/port/handler_http_api_tribe_change.go create mode 100644 internal/port/handler_http_api_tribe_change_test.go create mode 100644 internal/port/internal/apimodel/tribe_change.go diff --git a/api/openapi3.yml b/api/openapi3.yml index af5af47..d134a7b 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -16,6 +16,7 @@ tags: - name: players - name: villages - name: ennoblements + - name: tribe-changes servers: - url: "{scheme}://{hostname}/api" variables: @@ -92,7 +93,7 @@ paths: tags: - versions - servers - description: Get server config + description: Get the given server's config parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -107,7 +108,7 @@ paths: tags: - versions - servers - description: Get building info + description: Get the given server's building info parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -122,7 +123,7 @@ paths: tags: - versions - servers - description: Get unit info + description: Get the given server's unit info parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -215,7 +216,7 @@ paths: - servers - tribes - players - description: List tribe members + description: List the given tribe's members parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -272,7 +273,7 @@ paths: - servers - tribes - villages - description: List tribe villages + description: List the given tribe's villages parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -292,7 +293,7 @@ paths: - servers - players - villages - description: List player villages + description: List the given player's villages parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -333,7 +334,7 @@ paths: - servers - players - ennoblements - description: List player ennoblements + description: List the given player's ennoblements parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -356,7 +357,7 @@ paths: - servers - tribes - ennoblements - description: List tribe ennoblements + description: List the given tribe's ennoblements parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -379,7 +380,7 @@ paths: - servers - villages - ennoblements - description: List village ennoblements + description: List the given village's ennoblements parameters: - $ref: "#/components/parameters/VersionCodePathParam" - $ref: "#/components/parameters/ServerKeyPathParam" @@ -394,6 +395,52 @@ paths: $ref: "#/components/responses/ListEnnoblementsResponse" default: $ref: "#/components/responses/ErrorResponse" + /v2/versions/{versionCode}/servers/{serverKey}/players/{playerId}/tribe-changes: + get: + operationId: listPlayerTribeChanges + tags: + - versions + - servers + - players + - tribe-changes + description: List the given player's tribe changes + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/PlayerIdPathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/TribeChangeSortQueryParam" + - $ref: "#/components/parameters/SinceQueryParam" + - $ref: "#/components/parameters/BeforeQueryParam" + responses: + 200: + $ref: "#/components/responses/ListTribeChangesResponse" + default: + $ref: "#/components/responses/ErrorResponse" + /v2/versions/{versionCode}/servers/{serverKey}/tribes/{tribeId}/tribe-changes: + get: + operationId: listTribeTribeChanges + tags: + - versions + - servers + - tribes + - tribe-changes + description: List tribe membership changes + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/TribeIdPathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/TribeChangeSortQueryParam" + - $ref: "#/components/parameters/SinceQueryParam" + - $ref: "#/components/parameters/BeforeQueryParam" + responses: + 200: + $ref: "#/components/responses/ListTribeChangesResponse" + default: + $ref: "#/components/responses/ErrorResponse" components: schemas: Error: @@ -1364,6 +1411,22 @@ components: createdAt: type: string format: date-time + TribeChange: + type: object + required: + - id + - player + - createdAt + properties: + id: + $ref: "#/components/schemas/IntId" + player: + $ref: "#/components/schemas/PlayerMeta" + newTribe: + $ref: "#/components/schemas/TribeMeta" + createdAt: + type: string + format: date-time Cursor: type: object x-go-type-skip-optional-pointer: true @@ -1512,6 +1575,20 @@ components: - createdAt:ASC - createdAt:DESC maxItems: 1 + TribeChangeSortQueryParam: + 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 @@ -1735,6 +1812,21 @@ components: type: array items: $ref: "#/components/schemas/Ennoblement" + ListTribeChangesResponse: + description: "" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginationResponse" + - type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/TribeChange" ErrorResponse: description: Default error response. content: diff --git a/cmd/twhelp/cmd_serve.go b/cmd/twhelp/cmd_serve.go index 0a135d7..1e68219 100644 --- a/cmd/twhelp/cmd_serve.go +++ b/cmd/twhelp/cmd_serve.go @@ -111,12 +111,14 @@ var cmdServe = &cli.Command{ playerRepo := adapter.NewPlayerBunRepository(bunDB) villageRepo := adapter.NewVillageBunRepository(bunDB) ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB) + tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB) // services versionSvc := app.NewVersionService(versionRepo) serverSvc := app.NewServerService(serverRepo, nil, nil) tribeSvc := app.NewTribeService(tribeRepo, nil, nil) - playerSvc := app.NewPlayerService(playerRepo, nil, nil, nil) + tribeChangeSvc := app.NewTribeChangeService(tribeChangeRepo) + playerSvc := app.NewPlayerService(playerRepo, tribeChangeSvc, nil, nil) villageSvc := app.NewVillageService(villageRepo, nil, nil) ennoblementSvc := app.NewEnnoblementService(ennoblementRepo, nil, nil) @@ -148,6 +150,7 @@ var cmdServe = &cli.Command{ playerSvc, villageSvc, ennoblementSvc, + tribeChangeSvc, port.WithOpenAPIConfig(oapiCfg), )) diff --git a/internal/domain/tribe_change.go b/internal/domain/tribe_change.go index 400587a..b658160 100644 --- a/internal/domain/tribe_change.go +++ b/internal/domain/tribe_change.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "slices" + "strings" "time" ) @@ -263,6 +264,23 @@ const ( TribeChangeSortServerKeyDESC ) +func newTribeChangeSortFromString(s string) (TribeChangeSort, error) { + allowed := []TribeChangeSort{ + TribeChangeSortCreatedAtASC, + TribeChangeSortCreatedAtDESC, + } + + 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. TribeChangeSortIDASC and TribeChangeSortIDDESC). func (s TribeChangeSort) IsInConflict(s2 TribeChangeSort) bool { ss := []TribeChangeSort{s, s2} @@ -558,6 +576,37 @@ func (params *ListTribeChangesParams) SetLimit(limit int) error { return nil } +func (params *ListTribeChangesParams) PrependSortString(sort []string) error { + if err := validateSliceLen( + sort, + tribeChangeSortMinLength, + max(tribeChangeSortMaxLength-len(params.sort), 0), + ); err != nil { + return ValidationError{ + Model: listTribeChangesParamsModelName, + Field: "sort", + Err: err, + } + } + + toPrepend := make([]TribeChangeSort, 0, len(sort)) + + for i, s := range sort { + converted, err := newTribeChangeSortFromString(s) + if err != nil { + return SliceElementValidationError{ + Model: listTribeChangesParamsModelName, + Field: "sort", + Index: i, + Err: err, + } + } + toPrepend = append(toPrepend, converted) + } + + return params.SetSort(append(toPrepend, params.sort...)) +} + type ListTribeChangesResult struct { tribeChanges TribeChanges self TribeChangeCursor diff --git a/internal/domain/tribe_change_test.go b/internal/domain/tribe_change_test.go index 532618a..73c9e70 100644 --- a/internal/domain/tribe_change_test.go +++ b/internal/domain/tribe_change_test.go @@ -625,6 +625,143 @@ func TestListTribeChangesParams_SetSort(t *testing.T) { } } +func TestListTribeChangesParams_PrependSortString(t *testing.T) { + t.Parallel() + + defaultNewParams := func(t *testing.T) domain.ListTribeChangesParams { + t.Helper() + return domain.ListTribeChangesParams{} + } + + type args struct { + sort []string + } + + tests := []struct { + name string + newParams func(t *testing.T) domain.ListTribeChangesParams + args args + expectedSort []domain.TribeChangeSort + expectedErr error + }{ + { + name: "OK: [createdAt:ASC]", + args: args{ + sort: []string{ + "createdAt:ASC", + }, + }, + expectedSort: []domain.TribeChangeSort{ + domain.TribeChangeSortCreatedAtASC, + }, + }, + { + name: "OK: [createdAt:DESC]", + args: args{ + sort: []string{ + "createdAt:DESC", + }, + }, + expectedSort: []domain.TribeChangeSort{ + domain.TribeChangeSortCreatedAtDESC, + }, + }, + { + name: "ERR: len(sort) < 1", + args: args{ + sort: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribeChangesParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 3, + Current: 0, + }, + }, + }, + { + name: "ERR: custom params + len(sort) > 1", + newParams: func(t *testing.T) domain.ListTribeChangesParams { + t.Helper() + params := domain.NewListTribeChangesParams() + require.NoError(t, params.SetSort([]domain.TribeChangeSort{ + domain.TribeChangeSortServerKeyASC, + domain.TribeChangeSortIDASC, + })) + return params + }, + args: args{ + sort: []string{ + "createdAt:ASC", + "createdAt:DESC", + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribeChangesParams", + 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: "ListTribeChangesParams", + Field: "sort", + Index: 0, + Err: domain.UnsupportedSortStringError{ + Sort: "createdAt:", + }, + }, + }, + { + name: "ERR: conflict", + args: args{ + sort: []string{ + "createdAt:ASC", + "createdAt:DESC", + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribeChangesParams", + Field: "sort", + Err: domain.SortConflictError{ + Sort: [2]string{domain.TribeChangeSortCreatedAtASC.String(), domain.TribeChangeSortCreatedAtDESC.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 TestListTribeChangesParams_SetEncodedCursor(t *testing.T) { t.Parallel() diff --git a/internal/port/handler_http_api.go b/internal/port/handler_http_api.go index f6d7ff8..e2ebc1a 100644 --- a/internal/port/handler_http_api.go +++ b/internal/port/handler_http_api.go @@ -19,6 +19,7 @@ type apiHTTPHandler struct { playerSvc *app.PlayerService villageSvc *app.VillageService ennoblementSvc *app.EnnoblementService + tribeChangeSvc *app.TribeChangeService errorRenderer apiErrorRenderer openAPISchema func() (*openapi3.T, error) } @@ -36,6 +37,7 @@ func NewAPIHTTPHandler( playerSvc *app.PlayerService, villageSvc *app.VillageService, ennoblementSvc *app.EnnoblementService, + tribeChangeSvc *app.TribeChangeService, opts ...APIHTTPHandlerOption, ) http.Handler { cfg := newAPIHTTPHandlerConfig(opts...) @@ -47,6 +49,7 @@ func NewAPIHTTPHandler( playerSvc: playerSvc, villageSvc: villageSvc, ennoblementSvc: ennoblementSvc, + tribeChangeSvc: tribeChangeSvc, openAPISchema: sync.OnceValues(func() (*openapi3.T, error) { return getOpenAPISchema(cfg.openAPI) }), diff --git a/internal/port/handler_http_api_ennoblement_test.go b/internal/port/handler_http_api_ennoblement_test.go index e0139a3..41663cc 100644 --- a/internal/port/handler_http_api_ennoblement_test.go +++ b/internal/port/handler_http_api_ennoblement_test.go @@ -173,7 +173,7 @@ func TestListEnnoblements(t *testing.T) { }, }, { - name: "OK: after", + name: "OK: before", reqModifier: func(t *testing.T, req *http.Request) { t.Helper() @@ -820,7 +820,7 @@ func TestListPlayerEnnoblements(t *testing.T) { }, }, { - name: "OK: after", + name: "OK: before", reqModifier: func(t *testing.T, req *http.Request) { t.Helper() @@ -1520,7 +1520,7 @@ func TestListTribeEnnoblements(t *testing.T) { }, }, { - name: "OK: after", + name: "OK: before", reqModifier: func(t *testing.T, req *http.Request) { t.Helper() @@ -2212,7 +2212,7 @@ func TestListVillageEnnoblements(t *testing.T) { }, }, { - name: "OK: after", + name: "OK: before", reqModifier: func(t *testing.T, req *http.Request) { t.Helper() diff --git a/internal/port/handler_http_api_test.go b/internal/port/handler_http_api_test.go index 9830964..f2a97a6 100644 --- a/internal/port/handler_http_api_test.go +++ b/internal/port/handler_http_api_test.go @@ -39,6 +39,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h playerRepo := adapter.NewPlayerBunRepository(bunDB) villageRepo := adapter.NewVillageBunRepository(bunDB) ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB) + tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB) return port.NewAPIHTTPHandler( app.NewVersionService(versionRepo), @@ -47,6 +48,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h app.NewPlayerService(playerRepo, nil, nil, nil), app.NewVillageService(villageRepo, nil, nil), app.NewEnnoblementService(ennoblementRepo, nil, nil), + app.NewTribeChangeService(tribeChangeRepo), cfg.options..., ) } diff --git a/internal/port/handler_http_api_tribe_change.go b/internal/port/handler_http_api_tribe_change.go new file mode 100644 index 0000000..202598e --- /dev/null +++ b/internal/port/handler_http_api_tribe_change.go @@ -0,0 +1,192 @@ +package port + +import ( + "net/http" + "strconv" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" +) + +//nolint:gocyclo +func (h *apiHTTPHandler) ListPlayerTribeChanges( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + serverKey apimodel.ServerKeyPathParam, + playerID apimodel.PlayerIdPathParam, + params apimodel.ListPlayerTribeChangesParams, +) { + domainParams := domain.NewListTribeChangesParams() + + if err := domainParams.SetSort([]domain.TribeChangeSort{domain.TribeChangeSortIDASC}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetPlayerIDs([]int{playerID}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + + if params.Sort != nil { + if err := domainParams.PrependSortString(*params.Sort); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } else { + if err := domainParams.PrependSortString([]string{domain.TribeChangeSortCreatedAtASC.String()}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).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(formatListTribeChangesErrorPath).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(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + res, err := h.tribeChangeSvc.ListWithRelations(r.Context(), domainParams) + if err != nil { + h.errorRenderer.render(w, r, err) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListTribeChangesResponse(res)) +} + +//nolint:gocyclo +func (h *apiHTTPHandler) ListTribeTribeChanges( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + serverKey apimodel.ServerKeyPathParam, + tribeID apimodel.TribeIdPathParam, + params apimodel.ListTribeTribeChangesParams, +) { + domainParams := domain.NewListTribeChangesParams() + + if err := domainParams.SetSort([]domain.TribeChangeSort{domain.TribeChangeSortIDASC}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetTribeIDs([]int{tribeID}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + + if params.Sort != nil { + if err := domainParams.PrependSortString(*params.Sort); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } else { + if err := domainParams.PrependSortString([]string{domain.TribeChangeSortCreatedAtASC.String()}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).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(formatListTribeChangesErrorPath).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(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeChangesErrorPath).render(w, r, err) + return + } + } + + res, err := h.tribeChangeSvc.ListWithRelations(r.Context(), domainParams) + if err != nil { + h.errorRenderer.render(w, r, err) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListTribeChangesResponse(res)) +} + +func formatListTribeChangesErrorPath(segments []domain.ErrorPathSegment) []string { + if segments[0].Model != "ListTribeChangesParams" { + return nil + } + + switch segments[0].Field { + case "cursor": + return []string{"$query", "cursor"} + case "limit": + return []string{"$query", "limit"} + case "sort": + path := []string{"$query", "sort"} + if segments[0].Index >= 0 { + path = append(path, strconv.Itoa(segments[0].Index)) + } + return path + default: + return nil + } +} diff --git a/internal/port/handler_http_api_tribe_change_test.go b/internal/port/handler_http_api_tribe_change_test.go new file mode 100644 index 0000000..5dba4b7 --- /dev/null +++ b/internal/port/handler_http_api_tribe_change_test.go @@ -0,0 +1,1488 @@ +package port_test + +import ( + "cmp" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const endpointListPlayerTribeChanges = "/v2/versions/%s/servers/%s/players/%d/tribe-changes" + +func TestListPlayerTribeChanges(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + tcs := getAllTribeChanges(t, handler) + var server serverWithVersion + var player apimodel.PlayerMeta + + tcsGroupedByPlayerIDAndServerKey := make(map[string][]tribeChangeWithServer) + for _, tc := range tcs { + k := strconv.Itoa(tc.Player.Id) + tc.Server.Key + tcsGroupedByPlayerIDAndServerKey[k] = append(tcsGroupedByPlayerIDAndServerKey[k], tc) + } + currentMax := -1 + for _, grouped := range tcsGroupedByPlayerIDAndServerKey { + if l := len(grouped); l > currentMax && l > 0 { + currentMax = l + player = grouped[0].Player + server = grouped[0].Server + } + } + + 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.ListTribeChangesResponse](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.TribeChange) int { + return cmp.Or( + a.CreatedAt.Compare(b.CreatedAt), + 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.ListTribeChangesResponse](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.ListTribeChangesResponse](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.ListTribeChangesResponse](t, resp.Body) + assert.Equal(t, req.URL.Query().Get("cursor"), body.Cursor.Self) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + assert.Len(t, body.Data, limit) + }, + }, + { + name: "OK: sort=[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.ListTribeChangesResponse](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.TribeChange) 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 []tribeChangeWithServer + for _, tc := range tcs { + if tc.Server.Key == server.Key { + filtered = append(filtered, tc) + } + } + slices.SortFunc(filtered, func(a, b tribeChangeWithServer) 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.ListTribeChangesResponse](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 _, tc := range body.Data { + assert.True(t, tc.CreatedAt.After(since) || tc.CreatedAt.Equal(since)) + } + }, + }, + { + name: "OK: before", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + var filtered []tribeChangeWithServer + for _, tc := range tcs { + if tc.Server.Key == server.Key && tc.Player.Id == player.Id { + filtered = append(filtered, tc) + } + } + slices.SortFunc(filtered, func(a, b tribeChangeWithServer) 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.ListTribeChangesResponse](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 _, tc := range body.Data { + assert.True(t, tc.CreatedAt.Before(before)) + } + }, + }, + { + 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: fmt.Sprintf("ERR: limit > %d", domain.TribeChangeListMaxLimit), + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", strconv.Itoa(domain.TribeChangeListMaxLimit+1)) + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + domainErr := domain.MaxLessEqualError{ + Max: domain.TribeChangeListMaxLimit, + 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: 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) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListPlayerTribeChanges, 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( + endpointListPlayerTribeChanges, + 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( + endpointListPlayerTribeChanges, + server.Version.Code, + server.Key, + domaintest.RandID(), + ) + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + pathSegments := strings.Split(req.URL.Path, "/") + require.Len(t, pathSegments, 9) + id, err := strconv.Atoi(pathSegments[7]) + require.NoError(t, err) + domainErr := domain.PlayerNotFoundError{ + ID: id, + ServerKey: pathSegments[5], + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "id": float64(domainErr.ID), + "serverKey": domainErr.ServerKey, + }, + }, + }, + }, body) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf(endpointListPlayerTribeChanges, 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 endpointListTribeTribeChanges = "/v2/versions/%s/servers/%s/tribes/%d/tribe-changes" + +func TestListTribeTribeChanges(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + tcs := getAllTribeChanges(t, handler) + var server serverWithVersion + var tribe apimodel.TribeMeta + + tcsGroupedByTribeIDAndServerKey := make(map[string][]tribeChangeWithServer) + for _, tc := range tcs { + var keys []string + + if tc.NewTribe != nil { + keys = append(keys, strconv.Itoa(tc.NewTribe.Id)+tc.Server.Key) + } + + if tc.Player.Tribe != nil { + keys = append(keys, strconv.Itoa(tc.Player.Tribe.Id)+tc.Server.Key) + } + + for _, k := range keys { + tcsGroupedByTribeIDAndServerKey[k] = append(tcsGroupedByTribeIDAndServerKey[k], tc) + } + } + currentMax := -1 + for k, grouped := range tcsGroupedByTribeIDAndServerKey { + if l := len(grouped); l > currentMax && l > 0 { + currentMax = l + server = grouped[0].Server + id, err := strconv.Atoi(strings.TrimSuffix(k, grouped[0].Server.Key)) + require.NoError(t, err) + if grouped[0].NewTribe != nil && grouped[0].NewTribe.Id == id { + tribe = *grouped[0].NewTribe + } else if grouped[0].Player.Tribe != nil && grouped[0].Player.Tribe.Id == id { + tribe = *grouped[0].Player.Tribe + } + } + } + + 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.ListTribeChangesResponse](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.TribeChange) int { + return cmp.Or( + a.CreatedAt.Compare(b.CreatedAt), + 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.ListTribeChangesResponse](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.ListTribeChangesResponse](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.ListTribeChangesResponse](t, resp.Body) + assert.Equal(t, req.URL.Query().Get("cursor"), body.Cursor.Self) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + assert.Len(t, body.Data, limit) + }, + }, + { + name: "OK: sort=[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.ListTribeChangesResponse](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.TribeChange) 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 []tribeChangeWithServer + for _, tc := range tcs { + if tc.Server.Key == server.Key { + filtered = append(filtered, tc) + } + } + slices.SortFunc(filtered, func(a, b tribeChangeWithServer) 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.ListTribeChangesResponse](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 _, tc := range body.Data { + assert.True(t, tc.CreatedAt.After(since) || tc.CreatedAt.Equal(since)) + } + }, + }, + { + name: "OK: before", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + var filtered []tribeChangeWithServer + for _, tc := range tcs { + sameServerKey := tc.Server.Key == server.Key + newTribeIDCheck := tc.NewTribe != nil && tc.NewTribe.Id == tribe.Id + oldTribeIDCheck := tc.Player.Tribe != nil && tc.Player.Tribe.Id == tribe.Id + if sameServerKey && (newTribeIDCheck || oldTribeIDCheck) { + filtered = append(filtered, tc) + } + } + slices.SortFunc(filtered, func(a, b tribeChangeWithServer) 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.ListTribeChangesResponse](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 _, tc := range body.Data { + assert.True(t, tc.CreatedAt.Before(before)) + } + }, + }, + { + 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: fmt.Sprintf("ERR: limit > %d", domain.TribeChangeListMaxLimit), + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", strconv.Itoa(domain.TribeChangeListMaxLimit+1)) + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + domainErr := domain.MaxLessEqualError{ + Max: domain.TribeChangeListMaxLimit, + 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: 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) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListTribeTribeChanges, 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( + endpointListTribeTribeChanges, + 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( + endpointListTribeTribeChanges, + 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{ + ID: id, + ServerKey: pathSegments[5], + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "id": float64(domainErr.ID), + "serverKey": domainErr.ServerKey, + }, + }, + }, + }, body) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf(endpointListTribeTribeChanges, 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) + }) + } +} + +type tribeChangeWithServer struct { + apimodel.TribeChange + Server serverWithVersion +} + +func getAllTribeChanges(tb testing.TB, h http.Handler) []tribeChangeWithServer { + tb.Helper() + + players := getAllPlayers(tb, h) + + var tcs []tribeChangeWithServer + + for _, p := range players { + resp := doRequest(h, http.MethodGet, fmt.Sprintf( + endpointListPlayerTribeChanges, + p.Server.Version.Code, + p.Server.Key, + p.Id, + ), nil) + require.Equal(tb, http.StatusOK, resp.StatusCode) + + for _, tc := range decodeJSON[apimodel.ListTribeChangesResponse](tb, resp.Body).Data { + tcs = append(tcs, tribeChangeWithServer{ + TribeChange: tc, + Server: p.Server, + }) + } + + _ = resp.Body.Close() + } + + require.NotZero(tb, tcs) + + return tcs +} diff --git a/internal/port/internal/apimodel/tribe_change.go b/internal/port/internal/apimodel/tribe_change.go new file mode 100644 index 0000000..210682b --- /dev/null +++ b/internal/port/internal/apimodel/tribe_change.go @@ -0,0 +1,33 @@ +package apimodel + +import ( + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" +) + +func NewTribeChange(withRelations domain.TribeChangeWithRelations) TribeChange { + tc := withRelations.TribeChange() + return TribeChange{ + CreatedAt: tc.CreatedAt(), + Id: tc.ID(), + NewTribe: NewNullTribeMeta(withRelations.NewTribe()), + Player: NewPlayerMeta(withRelations.Player().WithRelations(withRelations.OldTribe())), + } +} + +func NewListTribeChangesResponse(res domain.ListTribeChangesWithRelationsResult) ListTribeChangesResponse { + tcs := res.TribeChanges() + + resp := ListTribeChangesResponse{ + Data: make([]TribeChange, 0, len(tcs)), + Cursor: Cursor{ + Next: res.Next().Encode(), + Self: res.Self().Encode(), + }, + } + + for _, tc := range tcs { + resp.Data = append(resp.Data, NewTribeChange(tc)) + } + + return resp +}