diff --git a/api/openapi3.yml b/api/openapi3.yml index d18ee15..3ba15be 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -17,6 +17,7 @@ tags: - name: villages - name: ennoblements - name: tribe-changes + - name: snapshots servers: - url: "{scheme}://{hostname}/api" variables: @@ -441,6 +442,27 @@ paths: $ref: "#/components/responses/ListTribeChangesResponse" default: $ref: "#/components/responses/ErrorResponse" + /v2/versions/{versionCode}/servers/{serverKey}/tribes/{tribeId}/snapshots: + get: + operationId: listTribeTribeSnapshots + tags: + - versions + - servers + - tribes + - snapshots + description: List the given tribe's snapshots + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/TribeIdPathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/TribeSnapshotSortQueryParam" + responses: + 200: + $ref: "#/components/responses/ListTribeSnapshotsResponse" + default: + $ref: "#/components/responses/ErrorResponse" components: schemas: DomainErrorCode: @@ -1484,6 +1506,42 @@ components: createdAt: type: string format: date-time + TribeSnapshot: + type: object + required: + - id + - tribe + - allPoints + - date + - dominance + - numMembers + - numVillages + - points + - rank + - opponentsDefeated + properties: + id: + $ref: "#/components/schemas/IntId" + tribe: + $ref: "#/components/schemas/TribeMeta" + allPoints: + type: integer + date: + type: string + format: date + dominance: + type: number + format: double + numMembers: + type: integer + numVillages: + type: integer + points: + type: integer + rank: + type: integer + opponentsDefeated: + $ref: "#/components/schemas/TribeOpponentsDefeated" CursorString: type: string minLength: 1 @@ -1632,7 +1690,7 @@ components: enum: - createdAt:ASC - createdAt:DESC - maxItems: 1 + maxItems: 2 TribeChangeSortQueryParam: name: sort in: query @@ -1646,7 +1704,21 @@ components: enum: - createdAt:ASC - createdAt:DESC - maxItems: 1 + maxItems: 2 + TribeSnapshotSortQueryParam: + name: sort + in: query + description: Order matters! + schema: + type: array + default: + - date:ASC + items: + type: string + enum: + - date:ASC + - date:DESC + maxItems: 2 SinceQueryParam: name: since in: query @@ -1885,6 +1957,21 @@ components: type: array items: $ref: "#/components/schemas/TribeChange" + ListTribeSnapshotsResponse: + description: "" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginationResponse" + - type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/TribeSnapshot" ErrorResponse: description: Default error response. content: diff --git a/cmd/twhelp/cmd_serve.go b/cmd/twhelp/cmd_serve.go index 6d019ee..c9e1e1a 100644 --- a/cmd/twhelp/cmd_serve.go +++ b/cmd/twhelp/cmd_serve.go @@ -113,6 +113,7 @@ var cmdServe = &cli.Command{ villageRepo := adapter.NewVillageBunRepository(bunDB) ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB) tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB) + tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(bunDB) // services versionSvc := app.NewVersionService(versionRepo) @@ -122,6 +123,7 @@ var cmdServe = &cli.Command{ playerSvc := app.NewPlayerService(playerRepo, tribeChangeSvc, nil, nil) villageSvc := app.NewVillageService(villageRepo, nil, nil) ennoblementSvc := app.NewEnnoblementService(ennoblementRepo, nil, nil) + tribeSnapshotSvc := app.NewTribeSnapshotService(tribeSnapshotRepo, tribeSvc, nil) // health h := health.New() @@ -156,6 +158,7 @@ var cmdServe = &cli.Command{ villageSvc, ennoblementSvc, tribeChangeSvc, + tribeSnapshotSvc, port.WithOpenAPIConfig(oapiCfg), )) }) diff --git a/internal/app/service_tribe_snapshot.go b/internal/app/service_tribe_snapshot.go index 2073486..f1189aa 100644 --- a/internal/app/service_tribe_snapshot.go +++ b/internal/app/service_tribe_snapshot.go @@ -11,6 +11,10 @@ type TribeSnapshotRepository interface { // Create persists tribe snapshots in a store (e.g. Postgres). // Duplicates are ignored. Create(ctx context.Context, params ...domain.CreateTribeSnapshotParams) error + ListWithRelations( + ctx context.Context, + params domain.ListTribeSnapshotsParams, + ) (domain.ListTribeSnapshotsWithRelationsResult, error) } type TribeSnapshotService struct { @@ -92,3 +96,10 @@ func (svc *TribeSnapshotService) Create( return nil } + +func (svc *TribeSnapshotService) ListWithRelations( + ctx context.Context, + params domain.ListTribeSnapshotsParams, +) (domain.ListTribeSnapshotsWithRelationsResult, error) { + return svc.repo.ListWithRelations(ctx, params) +} diff --git a/internal/port/handler_http_api.go b/internal/port/handler_http_api.go index fc70727..d31865f 100644 --- a/internal/port/handler_http_api.go +++ b/internal/port/handler_http_api.go @@ -13,15 +13,16 @@ import ( ) type apiHTTPHandler struct { - versionSvc *app.VersionService - serverSvc *app.ServerService - tribeSvc *app.TribeService - playerSvc *app.PlayerService - villageSvc *app.VillageService - ennoblementSvc *app.EnnoblementService - tribeChangeSvc *app.TribeChangeService - errorRenderer apiErrorRenderer - openAPISchema func() (*openapi3.T, error) + versionSvc *app.VersionService + serverSvc *app.ServerService + tribeSvc *app.TribeService + playerSvc *app.PlayerService + villageSvc *app.VillageService + ennoblementSvc *app.EnnoblementService + tribeChangeSvc *app.TribeChangeService + tribeSnapshotSvc *app.TribeSnapshotService + errorRenderer apiErrorRenderer + openAPISchema func() (*openapi3.T, error) } func WithOpenAPIConfig(oapiCfg OpenAPIConfig) APIHTTPHandlerOption { @@ -38,18 +39,20 @@ func NewAPIHTTPHandler( villageSvc *app.VillageService, ennoblementSvc *app.EnnoblementService, tribeChangeSvc *app.TribeChangeService, + tribeSnapshotSvc *app.TribeSnapshotService, opts ...APIHTTPHandlerOption, ) http.Handler { cfg := newAPIHTTPHandlerConfig(opts...) h := &apiHTTPHandler{ - versionSvc: versionSvc, - serverSvc: serverSvc, - tribeSvc: tribeSvc, - playerSvc: playerSvc, - villageSvc: villageSvc, - ennoblementSvc: ennoblementSvc, - tribeChangeSvc: tribeChangeSvc, + versionSvc: versionSvc, + serverSvc: serverSvc, + tribeSvc: tribeSvc, + playerSvc: playerSvc, + villageSvc: villageSvc, + ennoblementSvc: ennoblementSvc, + tribeChangeSvc: tribeChangeSvc, + tribeSnapshotSvc: tribeSnapshotSvc, openAPISchema: sync.OnceValues(func() (*openapi3.T, error) { return getOpenAPISchema(cfg.openAPI) }), diff --git a/internal/port/handler_http_api_test.go b/internal/port/handler_http_api_test.go index f2a97a6..fb9f85b 100644 --- a/internal/port/handler_http_api_test.go +++ b/internal/port/handler_http_api_test.go @@ -40,6 +40,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h villageRepo := adapter.NewVillageBunRepository(bunDB) ennoblementRepo := adapter.NewEnnoblementBunRepository(bunDB) tribeChangeRepo := adapter.NewTribeChangeBunRepository(bunDB) + tribeSnapshotRepo := adapter.NewTribeSnapshotBunRepository(bunDB) return port.NewAPIHTTPHandler( app.NewVersionService(versionRepo), @@ -49,6 +50,7 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h app.NewVillageService(villageRepo, nil, nil), app.NewEnnoblementService(ennoblementRepo, nil, nil), app.NewTribeChangeService(tribeChangeRepo), + app.NewTribeSnapshotService(tribeSnapshotRepo, nil, nil), cfg.options..., ) } diff --git a/internal/port/handler_http_api_tribe_snapshot.go b/internal/port/handler_http_api_tribe_snapshot.go new file mode 100644 index 0000000..a3868ec --- /dev/null +++ b/internal/port/handler_http_api_tribe_snapshot.go @@ -0,0 +1,91 @@ +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) ListTribeTribeSnapshots( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + serverKey apimodel.ServerKeyPathParam, + tribeID apimodel.TribeIdPathParam, + params apimodel.ListTribeTribeSnapshotsParams, +) { + domainParams := domain.NewListTribeSnapshotsParams() + + if err := domainParams.SetSort([]domain.TribeSnapshotSort{domain.TribeSnapshotSortIDASC}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetTribeIDs([]int{tribeID}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + + if params.Sort != nil { + if err := domainParams.PrependSortString(*params.Sort); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + } else { + if err := domainParams.PrependSortString([]string{domain.TribeSnapshotSortDateASC.String()}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + } + + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListTribeSnapshotsErrorPath).render(w, r, err) + return + } + } + + res, err := h.tribeSnapshotSvc.ListWithRelations(r.Context(), domainParams) + if err != nil { + h.errorRenderer.render(w, r, err) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListTribeSnapshotsResponse(res)) +} + +func formatListTribeSnapshotsErrorPath(segments []domain.ErrorPathSegment) []string { + if segments[0].Model != "ListTribeSnapshotsParams" { + 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_snapshot_test.go b/internal/port/handler_http_api_tribe_snapshot_test.go new file mode 100644 index 0000000..cd447d7 --- /dev/null +++ b/internal/port/handler_http_api_tribe_snapshot_test.go @@ -0,0 +1,626 @@ +package port_test + +import ( + "cmp" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + + "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 endpointListTribeTribeSnapshots = "/v2/versions/%s/servers/%s/tribes/%d/snapshots" + +func TestListTribeTribeSnapshots(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + tss := getAllTribeSnapshots(t, handler) + var server serverWithVersion + var tribe apimodel.TribeMeta + + tssGroupedByTribeIDAndServerKey := make(map[string][]tribeSnapshotWithServer) + for _, ts := range tss { + key := fmt.Sprintf("%d-%s", ts.Tribe.Id, ts.Server.Key) + tssGroupedByTribeIDAndServerKey[key] = append(tssGroupedByTribeIDAndServerKey[key], ts) + } + currentMax := -1 + for _, grouped := range tssGroupedByTribeIDAndServerKey { + if l := len(grouped); l > currentMax && l > 0 { + currentMax = l + server = grouped[0].Server + tribe = grouped[0].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.ListTribeSnapshotsResponse](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.TribeSnapshot) int { + return cmp.Or( + a.Date.Compare(b.Date.Time), + 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.ListTribeSnapshotsResponse](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.ListTribeSnapshotsResponse](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.ListTribeSnapshotsResponse](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=[date:DESC]", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("sort", "date: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.ListTribeSnapshotsResponse](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.TribeSnapshot) int { + return cmp.Or( + a.Date.Compare(b.Date.Time)*-1, + cmp.Compare(a.Id, b.Id), + ) + })) + }, + }, + { + 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: apimodel.ErrorCode(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.TribeSnapshotListMaxLimit), + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", strconv.Itoa(domain.TribeSnapshotListMaxLimit+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.TribeSnapshotListMaxLimit, + Current: limit, + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: apimodel.ErrorCode(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: apimodel.ErrorCode(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: apimodel.ErrorCode(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: apimodel.ErrorCode(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", "date:DESC") + q.Add("sort", "date:ASC") + q.Add("sort", "date: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: apimodel.ErrorCode(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", "date:") + 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: apimodel.ErrorCode(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", "date:DESC") + q.Add("sort", "date: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: apimodel.ErrorCode(domainErr.Code()), + Message: domainErr.Error(), + Params: map[string]any{ + "sort": paramSort, + }, + Path: []string{"$query", "sort"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: version not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf( + endpointListTribeTribeSnapshots, + randInvalidVersionCode(t, handler), + 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: apimodel.ErrorCode(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( + endpointListTribeTribeSnapshots, + 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: apimodel.ErrorCode(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( + endpointListTribeTribeSnapshots, + 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: apimodel.ErrorCode(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(endpointListTribeTribeSnapshots, 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 tribeSnapshotWithServer struct { + apimodel.TribeSnapshot + Server serverWithVersion +} + +func getAllTribeSnapshots(tb testing.TB, h http.Handler) []tribeSnapshotWithServer { + tb.Helper() + + tribes := getAllTribes(tb, h) + + var tss []tribeSnapshotWithServer + + for _, t := range tribes { + resp := doRequest(h, http.MethodGet, fmt.Sprintf( + endpointListTribeTribeSnapshots, + t.Server.Version.Code, + t.Server.Key, + t.Id, + ), nil) + require.Equal(tb, http.StatusOK, resp.StatusCode) + + for _, ts := range decodeJSON[apimodel.ListTribeSnapshotsResponse](tb, resp.Body).Data { + tss = append(tss, tribeSnapshotWithServer{ + TribeSnapshot: ts, + Server: t.Server, + }) + } + + _ = resp.Body.Close() + } + + require.NotZero(tb, tss) + + return tss +} diff --git a/internal/port/internal/apimodel/tribe_snapshot.go b/internal/port/internal/apimodel/tribe_snapshot.go new file mode 100644 index 0000000..0657ea9 --- /dev/null +++ b/internal/port/internal/apimodel/tribe_snapshot.go @@ -0,0 +1,40 @@ +package apimodel + +import ( + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + oapitypes "github.com/oapi-codegen/runtime/types" +) + +func NewTribeSnapshot(withRelations domain.TribeSnapshotWithRelations) TribeSnapshot { + tc := withRelations.TribeSnapshot() + return TribeSnapshot{ + AllPoints: tc.AllPoints(), + Date: oapitypes.Date{Time: tc.Date()}, + Dominance: tc.Dominance(), + Id: tc.ID(), + NumMembers: tc.NumMembers(), + NumVillages: tc.NumVillages(), + OpponentsDefeated: NewTribeOpponentsDefeated(tc.OD()), + Points: tc.Points(), + Rank: tc.Rank(), + Tribe: NewTribeMeta(withRelations.Tribe()), + } +} + +func NewListTribeSnapshotsResponse(res domain.ListTribeSnapshotsWithRelationsResult) ListTribeSnapshotsResponse { + tcs := res.TribeSnapshots() + + resp := ListTribeSnapshotsResponse{ + Data: make([]TribeSnapshot, 0, len(tcs)), + Cursor: Cursor{ + Next: res.Next().Encode(), + Self: res.Self().Encode(), + }, + } + + for _, tc := range tcs { + resp.Data = append(resp.Data, NewTribeSnapshot(tc)) + } + + return resp +}