diff --git a/api/openapi3.yml b/api/openapi3.yml index 070376b..76d43d2 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -180,6 +180,7 @@ paths: - $ref: "#/components/parameters/CursorQueryParam" - $ref: "#/components/parameters/LimitQueryParam" - $ref: "#/components/parameters/PlayerDeletedQueryParam" + - $ref: "#/components/parameters/PlayerSortQueryParam" responses: 200: $ref: "#/components/responses/ListPlayersResponse" @@ -1100,6 +1101,26 @@ components: schema: type: boolean required: false + PlayerSortQueryParam: + name: sort + in: query + description: Order matters! + schema: + type: array + items: + type: string + enum: + - odScoreAtt:ASC + - odScoreAtt:DESC + - odScoreDef:ASC + - odScoreDef:DESC + - odScoreTotal:ASC + - odScoreTotal:DESC + - points:ASC + - points:DESC + - deletedAt:ASC + - deletedAt:DESC + maxItems: 2 VersionCodePathParam: in: path name: versionCode diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index c511335..c3aee5e 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -3,6 +3,5 @@ package adapter import "errors" var ( - errUnsupportedSortValue = errors.New("unsupported sort value") - errSortNoUniqueField = errors.New("no unique field used for sorting") + errInvalidSortValue = errors.New("invalid sort value") ) diff --git a/internal/adapter/bun_utils.go b/internal/adapter/bun_utils.go index 2833d49..42ec31b 100644 --- a/internal/adapter/bun_utils.go +++ b/internal/adapter/bun_utils.go @@ -1,6 +1,11 @@ package adapter -import "github.com/uptrace/bun" +import ( + "errors" + "slices" + + "github.com/uptrace/bun" +) func appendODSetClauses(q *bun.InsertQuery) *bun.InsertQuery { return q.Set("rank_att = EXCLUDED.rank_att"). @@ -23,3 +28,90 @@ func separateListResultAndNext[T any](res []T, limit int) ([]T, T) { return res, next } + +type sortDirection uint8 + +var errInvalidSortDirection = errors.New("invalid sort direction") + +const ( + sortDirectionASC sortDirection = iota + 1 + sortDirectionDESC +) + +func (sd sortDirection) String() string { + switch sd { + case sortDirectionASC: + return "ASC" + case sortDirectionDESC: + return "DESC" + default: + return "unknown" + } +} + +type cursorPaginationApplierDataElement struct { + unique bool + column string + direction sortDirection + value any +} + +type cursorPaginationApplier struct { + data []cursorPaginationApplierDataElement +} + +var errCursorNoUniqueFieldUsedForSorting = errors.New("cursor - no unique field used for sorting") + +func (a cursorPaginationApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { + if len(a.data) == 0 { + return q + } + + uniqueIndex := slices.IndexFunc(a.data, func(element cursorPaginationApplierDataElement) bool { + return element.unique + }) + if uniqueIndex < 0 { + return q.Err(errCursorNoUniqueFieldUsedForSorting) + } + + return q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + dataLen := len(a.data) + + // based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245 + + return q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { + for i := 0; i < dataLen; i++ { + q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { + current := a.data[i] + + for j := 0; j < i; j++ { + prev := a.data[j] + q = q.Where("? = ?", bun.Safe(prev.column), prev.value) + } + + column := bun.Safe(current.column) + greaterSymbol := bun.Safe(">") + lessSymbol := bun.Safe("<") + + if i == dataLen-1 { + greaterSymbol = ">=" + lessSymbol = "<=" + } + + switch current.direction { + case sortDirectionASC: + q = q.Where("? ? ?", column, greaterSymbol, current.value) + case sortDirectionDESC: + q = q.Where("? ? ?", column, lessSymbol, current.value) + default: + return q.Err(errInvalidSortDirection) + } + + return q + }) + } + + return q + }) + }) +} diff --git a/internal/adapter/repository_bun_ennoblement.go b/internal/adapter/repository_bun_ennoblement.go index 408122e..f937384 100644 --- a/internal/adapter/repository_bun_ennoblement.go +++ b/internal/adapter/repository_bun_ennoblement.go @@ -92,7 +92,7 @@ func (a listEnnoblementsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer case domain.EnnoblementSortServerKeyDESC: q = q.Order("ennoblement.server_key DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } diff --git a/internal/adapter/repository_bun_player.go b/internal/adapter/repository_bun_player.go index 1c55c50..8191a1c 100644 --- a/internal/adapter/repository_bun_player.go +++ b/internal/adapter/repository_bun_player.go @@ -162,8 +162,28 @@ func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Order("player.server_key ASC") case domain.PlayerSortServerKeyDESC: q = q.Order("player.server_key DESC") + case domain.PlayerSortODScoreAttASC: + q = q.Order("player.score_att ASC") + case domain.PlayerSortODScoreAttDESC: + q = q.Order("player.score_att DESC") + case domain.PlayerSortODScoreDefASC: + q = q.Order("player.score_def ASC") + case domain.PlayerSortODScoreDefDESC: + q = q.Order("player.score_def DESC") + case domain.PlayerSortODScoreTotalASC: + q = q.Order("player.score_total ASC") + case domain.PlayerSortODScoreTotalDESC: + q = q.Order("player.score_total DESC") + case domain.PlayerSortPointsASC: + q = q.Order("player.points ASC") + case domain.PlayerSortPointsDESC: + q = q.Order("player.points DESC") + case domain.PlayerSortDeletedAtASC: + q = q.OrderExpr("COALESCE(player.deleted_at, ?) ASC", time.Time{}) + case domain.PlayerSortDeletedAtDESC: + q = q.OrderExpr("COALESCE(player.deleted_at, ?) DESC", time.Time{}) default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } @@ -172,85 +192,97 @@ func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { //nolint:gocyclo func (a listPlayersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { - if a.params.Cursor().IsZero() { + cursor := a.params.Cursor() + + if cursor.IsZero() { return q } - q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { - cursor := a.params.Cursor() - cursorID := cursor.ID() - cursorServerKey := cursor.ServerKey() + sort := a.params.Sort() + cursorApplier := cursorPaginationApplier{ + data: make([]cursorPaginationApplierDataElement, 0, len(sort)), + } - sort := a.params.Sort() - sortLen := len(sort) + for _, s := range sort { + var el cursorPaginationApplierDataElement - // based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245 - - switch { - case sortLen == 1: - switch sort[0] { - case domain.PlayerSortIDASC: - q = q.Where("player.id >= ?", cursorID) - case domain.PlayerSortIDDESC: - q = q.Where("player.id <= ?", cursorID) - case domain.PlayerSortServerKeyASC, - domain.PlayerSortServerKeyDESC: - return q.Err(errSortNoUniqueField) - default: - return q.Err(errUnsupportedSortValue) - } - case sortLen > 1: - q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - for i := 0; i < sortLen; i++ { - q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - current := sort[i] - - for j := 0; j < i; j++ { - s := sort[j] - - switch s { - case domain.PlayerSortIDASC, - domain.PlayerSortIDDESC: - q = q.Where("player.id = ?", cursorID) - case domain.PlayerSortServerKeyASC, - domain.PlayerSortServerKeyDESC: - q = q.Where("player.server_key = ?", cursorServerKey) - default: - return q.Err(errUnsupportedSortValue) - } - } - - greaterSymbol := bun.Safe(">") - lessSymbol := bun.Safe("<") - - if i == sortLen-1 { - greaterSymbol = ">=" - lessSymbol = "<=" - } - - switch current { - case domain.PlayerSortIDASC: - q = q.Where("player.id ? ?", greaterSymbol, cursorID) - case domain.PlayerSortIDDESC: - q = q.Where("player.id ? ?", lessSymbol, cursorID) - case domain.PlayerSortServerKeyASC: - q = q.Where("player.server_key ? ?", greaterSymbol, cursorServerKey) - case domain.PlayerSortServerKeyDESC: - q = q.Where("player.server_key ? ?", lessSymbol, cursorServerKey) - default: - return q.Err(errUnsupportedSortValue) - } - - return q - }) - } - - return q - }) + switch s { + case domain.PlayerSortIDASC: + el.value = cursor.ID() + el.unique = true + el.column = "player.id" + el.direction = sortDirectionASC + case domain.PlayerSortIDDESC: + el.value = cursor.ID() + el.unique = true + el.column = "player.id" + el.direction = sortDirectionDESC + case domain.PlayerSortServerKeyASC: + el.value = cursor.ServerKey() + el.unique = false + el.column = "player.server_key" + el.direction = sortDirectionASC + case domain.PlayerSortServerKeyDESC: + el.value = cursor.ServerKey() + el.unique = false + el.column = "player.server_key" + el.direction = sortDirectionDESC + case domain.PlayerSortODScoreAttASC: + el.value = cursor.ODScoreAtt() + el.unique = false + el.column = "player.score_att" + el.direction = sortDirectionASC + case domain.PlayerSortODScoreAttDESC: + el.value = cursor.ODScoreAtt() + el.unique = false + el.column = "player.score_att" + el.direction = sortDirectionDESC + case domain.PlayerSortODScoreDefASC: + el.value = cursor.ODScoreDef() + el.unique = false + el.column = "player.score_def" + el.direction = sortDirectionASC + case domain.PlayerSortODScoreDefDESC: + el.value = cursor.ODScoreDef() + el.unique = false + el.column = "player.score_def" + el.direction = sortDirectionDESC + case domain.PlayerSortODScoreTotalASC: + el.value = cursor.ODScoreTotal() + el.unique = false + el.column = "player.score_total" + el.direction = sortDirectionASC + case domain.PlayerSortODScoreTotalDESC: + el.value = cursor.ODScoreTotal() + el.unique = false + el.column = "player.score_total" + el.direction = sortDirectionDESC + case domain.PlayerSortPointsASC: + el.value = cursor.Points() + el.unique = false + el.column = "player.points" + el.direction = sortDirectionASC + case domain.PlayerSortPointsDESC: + el.value = cursor.Points() + el.unique = false + el.column = "player.points" + el.direction = sortDirectionDESC + case domain.PlayerSortDeletedAtASC: + el.value = cursor.DeletedAt() + el.unique = false + el.column = "COALESCE(player.deleted_at, '0001-01-01 00:00:00+00:00')" + el.direction = sortDirectionASC + case domain.PlayerSortDeletedAtDESC: + el.value = cursor.DeletedAt() + el.unique = false + el.column = "COALESCE(player.deleted_at, '0001-01-01 00:00:00+00:00')" + el.direction = sortDirectionDESC + default: + return q.Err(errInvalidSortValue) } - return q - }) + cursorApplier.data = append(cursorApplier.data, el) + } - return q + return q.Apply(cursorApplier.apply) } diff --git a/internal/adapter/repository_bun_player_snapshot.go b/internal/adapter/repository_bun_player_snapshot.go index 64a954e..418327f 100644 --- a/internal/adapter/repository_bun_player_snapshot.go +++ b/internal/adapter/repository_bun_player_snapshot.go @@ -97,7 +97,7 @@ func (a listPlayerSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQ case domain.PlayerSnapshotSortServerKeyDESC: q = q.Order("ps.server_key DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index 61ff7ea..c3d71e8 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -228,92 +228,55 @@ func (a listServersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.ServerSortOpenDESC: q = q.Order("server.open DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } return q.Apply(a.applyCursor).Limit(a.params.Limit() + 1) } -//nolint:gocyclo func (a listServersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { - if a.params.Cursor().IsZero() { + cursor := a.params.Cursor() + + if cursor.IsZero() { return q } - q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { - cursorKey := a.params.Cursor().Key() - cursorOpen := a.params.Cursor().Open() + sort := a.params.Sort() + cursorApplier := cursorPaginationApplier{ + data: make([]cursorPaginationApplierDataElement, 0, len(sort)), + } - sort := a.params.Sort() - sortLen := len(sort) + for _, s := range sort { + var el cursorPaginationApplierDataElement - // based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245 - - switch { - case sortLen == 1: - switch sort[0] { - case domain.ServerSortKeyASC: - q = q.Where("server.key >= ?", cursorKey) - case domain.ServerSortKeyDESC: - q = q.Where("server.key <= ?", cursorKey) - case domain.ServerSortOpenASC, domain.ServerSortOpenDESC: - return q.Err(errSortNoUniqueField) - default: - return q.Err(errUnsupportedSortValue) - } - case sortLen > 1: - q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - for i := 0; i < sortLen; i++ { - q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - current := sort[i] - - for j := 0; j < i; j++ { - s := sort[j] - - switch s { - case domain.ServerSortKeyASC, - domain.ServerSortKeyDESC: - q = q.Where("server.key = ?", cursorKey) - case domain.ServerSortOpenASC, - domain.ServerSortOpenDESC: - q = q.Where("server.open = ?", cursorOpen) - default: - return q.Err(errUnsupportedSortValue) - } - } - - greaterSymbol := bun.Safe(">") - lessSymbol := bun.Safe("<") - - if i == sortLen-1 { - greaterSymbol = ">=" - lessSymbol = "<=" - } - - switch current { - case domain.ServerSortKeyASC: - q = q.Where("server.key ? ?", greaterSymbol, cursorKey) - case domain.ServerSortKeyDESC: - q = q.Where("server.key ? ?", lessSymbol, cursorKey) - case domain.ServerSortOpenASC: - q = q.Where("server.open ? ?", greaterSymbol, cursorOpen) - case domain.ServerSortOpenDESC: - q = q.Where("server.open ? ?", lessSymbol, cursorOpen) - default: - return q.Err(errUnsupportedSortValue) - } - - return q - }) - } - - return q - }) + switch s { + case domain.ServerSortKeyASC: + el.value = cursor.Key() + el.unique = true + el.column = "server.key" + el.direction = sortDirectionASC + case domain.ServerSortKeyDESC: + el.value = cursor.Key() + el.unique = true + el.column = "server.key" + el.direction = sortDirectionDESC + case domain.ServerSortOpenASC: + el.value = cursor.Open() + el.unique = false + el.column = "server.open" + el.direction = sortDirectionASC + case domain.ServerSortOpenDESC: + el.value = cursor.Open() + el.unique = false + el.column = "server.open" + el.direction = sortDirectionDESC + default: + return q.Err(errInvalidSortValue) } - return q - }) + cursorApplier.data = append(cursorApplier.data, el) + } - return q + return q.Apply(cursorApplier.apply) } diff --git a/internal/adapter/repository_bun_tribe.go b/internal/adapter/repository_bun_tribe.go index 2a383a2..4b36562 100644 --- a/internal/adapter/repository_bun_tribe.go +++ b/internal/adapter/repository_bun_tribe.go @@ -117,6 +117,10 @@ func (repo *TribeBunRepository) List( ) (domain.ListTribesResult, error) { var tribes bunmodel.Tribes + fmt.Println(repo.db.NewSelect(). + Model(&tribes). + Apply(listTribesParamsApplier{params: params}.apply).String()) + if err := repo.db.NewSelect(). Model(&tribes). Apply(listTribesParamsApplier{params: params}.apply). @@ -212,7 +216,7 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.TribeSortDeletedAtDESC: q = q.OrderExpr("COALESCE(tribe.deleted_at, ?) DESC", time.Time{}) default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } @@ -221,145 +225,107 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { //nolint:gocyclo func (a listTribesParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { - if a.params.Cursor().IsZero() { + cursor := a.params.Cursor() + + if cursor.IsZero() { return q } - q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { - cursor := a.params.Cursor() - cursorID := cursor.ID() - cursorServerKey := cursor.ServerKey() - cursorODScoreAtt := cursor.ODScoreAtt() - cursorODScoreDef := cursor.ODScoreDef() - cursorODScoreTotal := cursor.ODScoreTotal() - cursorPoints := cursor.Points() - cursorDominance := cursor.Dominance() - cursorDeletedAt := cursor.DeletedAt() + sort := a.params.Sort() + cursorApplier := cursorPaginationApplier{ + data: make([]cursorPaginationApplierDataElement, 0, len(sort)), + } - sort := a.params.Sort() - sortLen := len(sort) + for _, s := range sort { + var el cursorPaginationApplierDataElement - // based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245 - - switch { - case sortLen == 1: - switch sort[0] { - case domain.TribeSortIDASC: - q = q.Where("tribe.id >= ?", cursorID) - case domain.TribeSortIDDESC: - q = q.Where("tribe.id <= ?", cursorID) - case domain.TribeSortServerKeyASC, - domain.TribeSortServerKeyDESC, - domain.TribeSortODScoreAttASC, - domain.TribeSortODScoreAttDESC, - domain.TribeSortODScoreDefASC, - domain.TribeSortODScoreDefDESC, - domain.TribeSortODScoreTotalASC, - domain.TribeSortODScoreTotalDESC, - domain.TribeSortPointsASC, - domain.TribeSortPointsDESC, - domain.TribeSortDominanceASC, - domain.TribeSortDominanceDESC, - domain.TribeSortDeletedAtASC, - domain.TribeSortDeletedAtDESC: - return q.Err(errSortNoUniqueField) - default: - return q.Err(errUnsupportedSortValue) - } - case sortLen > 1: - q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - for i := 0; i < sortLen; i++ { - q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { - current := sort[i] - - for j := 0; j < i; j++ { - s := sort[j] - - switch s { - case domain.TribeSortIDASC, - domain.TribeSortIDDESC: - q = q.Where("tribe.id = ?", cursorID) - case domain.TribeSortServerKeyASC, - domain.TribeSortServerKeyDESC: - q = q.Where("tribe.server_key = ?", cursorServerKey) - case domain.TribeSortODScoreAttASC, - domain.TribeSortODScoreAttDESC: - q = q.Where("tribe.score_att = ?", cursorODScoreAtt) - case domain.TribeSortODScoreDefASC, - domain.TribeSortODScoreDefDESC: - q = q.Where("tribe.score_def = ?", cursorODScoreDef) - case domain.TribeSortODScoreTotalASC, - domain.TribeSortODScoreTotalDESC: - q = q.Where("tribe.score_total = ?", cursorODScoreTotal) - case domain.TribeSortPointsASC, - domain.TribeSortPointsDESC: - q = q.Where("tribe.points = ?", cursorPoints) - case domain.TribeSortDominanceASC, - domain.TribeSortDominanceDESC: - q = q.Where("tribe.dominance = ?", cursorDominance) - case domain.TribeSortDeletedAtASC, - domain.TribeSortDeletedAtDESC: - q = q.Where("COALESCE(tribe.deleted_at, ?) = ?", time.Time{}, cursorDeletedAt) - default: - return q.Err(errUnsupportedSortValue) - } - } - - greaterSymbol := bun.Safe(">") - lessSymbol := bun.Safe("<") - - if i == sortLen-1 { - greaterSymbol = ">=" - lessSymbol = "<=" - } - - switch current { - case domain.TribeSortIDASC: - q = q.Where("tribe.id ? ?", greaterSymbol, cursorID) - case domain.TribeSortIDDESC: - q = q.Where("tribe.id ? ?", lessSymbol, cursorID) - case domain.TribeSortServerKeyASC: - q = q.Where("tribe.server_key ? ?", greaterSymbol, cursorServerKey) - case domain.TribeSortServerKeyDESC: - q = q.Where("tribe.server_key ? ?", lessSymbol, cursorServerKey) - case domain.TribeSortODScoreAttASC: - q = q.Where("tribe.score_att ? ?", greaterSymbol, cursorODScoreAtt) - case domain.TribeSortODScoreAttDESC: - q = q.Where("tribe.score_att ? ?", lessSymbol, cursorODScoreAtt) - case domain.TribeSortODScoreDefASC: - q = q.Where("tribe.score_def ? ?", greaterSymbol, cursorODScoreDef) - case domain.TribeSortODScoreDefDESC: - q = q.Where("tribe.score_def ? ?", lessSymbol, cursorODScoreDef) - case domain.TribeSortODScoreTotalASC: - q = q.Where("tribe.score_total ? ?", greaterSymbol, cursorODScoreTotal) - case domain.TribeSortODScoreTotalDESC: - q = q.Where("tribe.score_total ? ?", lessSymbol, cursorODScoreTotal) - case domain.TribeSortPointsASC: - q = q.Where("tribe.points ? ?", greaterSymbol, cursorPoints) - case domain.TribeSortPointsDESC: - q = q.Where("tribe.points ? ?", lessSymbol, cursorPoints) - case domain.TribeSortDominanceASC: - q = q.Where("tribe.dominance ? ?", greaterSymbol, cursorDominance) - case domain.TribeSortDominanceDESC: - q = q.Where("tribe.dominance ? ?", lessSymbol, cursorDominance) - case domain.TribeSortDeletedAtASC: - q = q.Where("COALESCE(tribe.deleted_at, ?) ? ?", time.Time{}, greaterSymbol, cursorDeletedAt) - case domain.TribeSortDeletedAtDESC: - q = q.Where("COALESCE(tribe.deleted_at, ?) ? ?", time.Time{}, lessSymbol, cursorDeletedAt) - default: - return q.Err(errUnsupportedSortValue) - } - - return q - }) - } - - return q - }) + switch s { + case domain.TribeSortIDASC: + el.value = cursor.ID() + el.unique = true + el.column = "tribe.id" + el.direction = sortDirectionASC + case domain.TribeSortIDDESC: + el.value = cursor.ID() + el.unique = true + el.column = "tribe.id" + el.direction = sortDirectionDESC + case domain.TribeSortServerKeyASC: + el.value = cursor.ServerKey() + el.unique = false + el.column = "tribe.server_key" + el.direction = sortDirectionASC + case domain.TribeSortServerKeyDESC: + el.value = cursor.ServerKey() + el.unique = false + el.column = "tribe.server_key" + el.direction = sortDirectionDESC + case domain.TribeSortODScoreAttASC: + el.value = cursor.ODScoreAtt() + el.unique = false + el.column = "tribe.score_att" + el.direction = sortDirectionASC + case domain.TribeSortODScoreAttDESC: + el.value = cursor.ODScoreAtt() + el.unique = false + el.column = "tribe.score_att" + el.direction = sortDirectionDESC + case domain.TribeSortODScoreDefASC: + el.value = cursor.ODScoreDef() + el.unique = false + el.column = "tribe.score_def" + el.direction = sortDirectionASC + case domain.TribeSortODScoreDefDESC: + el.value = cursor.ODScoreDef() + el.unique = false + el.column = "tribe.score_def" + el.direction = sortDirectionDESC + case domain.TribeSortODScoreTotalASC: + el.value = cursor.ODScoreTotal() + el.unique = false + el.column = "tribe.score_total" + el.direction = sortDirectionASC + case domain.TribeSortODScoreTotalDESC: + el.value = cursor.ODScoreTotal() + el.unique = false + el.column = "tribe.score_total" + el.direction = sortDirectionDESC + case domain.TribeSortPointsASC: + el.value = cursor.Points() + el.unique = false + el.column = "tribe.points" + el.direction = sortDirectionASC + case domain.TribeSortPointsDESC: + el.value = cursor.Points() + el.unique = false + el.column = "tribe.points" + el.direction = sortDirectionDESC + case domain.TribeSortDominanceASC: + el.value = cursor.Dominance() + el.unique = false + el.column = "tribe.dominance" + el.direction = sortDirectionASC + case domain.TribeSortDominanceDESC: + el.value = cursor.Dominance() + el.unique = false + el.column = "tribe.dominance" + el.direction = sortDirectionDESC + case domain.TribeSortDeletedAtASC: + el.value = cursor.DeletedAt() + el.unique = false + el.column = "COALESCE(tribe.deleted_at, '0001-01-01 00:00:00+00:00')" + el.direction = sortDirectionASC + case domain.TribeSortDeletedAtDESC: + el.value = cursor.DeletedAt() + el.unique = false + el.column = "COALESCE(tribe.deleted_at, '0001-01-01 00:00:00+00:00')" + el.direction = sortDirectionDESC + default: + return q.Err(errInvalidSortValue) } - return q - }) + cursorApplier.data = append(cursorApplier.data, el) + } - return q + return q.Apply(cursorApplier.apply) } diff --git a/internal/adapter/repository_bun_tribe_change.go b/internal/adapter/repository_bun_tribe_change.go index db8c00b..703d297 100644 --- a/internal/adapter/repository_bun_tribe_change.go +++ b/internal/adapter/repository_bun_tribe_change.go @@ -90,7 +90,7 @@ func (a listTribeChangesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer case domain.TribeChangeSortServerKeyDESC: q = q.Order("tc.server_key DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } diff --git a/internal/adapter/repository_bun_tribe_snapshot.go b/internal/adapter/repository_bun_tribe_snapshot.go index d42402d..5e646e4 100644 --- a/internal/adapter/repository_bun_tribe_snapshot.go +++ b/internal/adapter/repository_bun_tribe_snapshot.go @@ -96,7 +96,7 @@ func (a listTribeSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQu case domain.TribeSnapshotSortServerKeyDESC: q = q.Order("ts.server_key DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } diff --git a/internal/adapter/repository_bun_version.go b/internal/adapter/repository_bun_version.go index 646c438..4451987 100644 --- a/internal/adapter/repository_bun_version.go +++ b/internal/adapter/repository_bun_version.go @@ -50,26 +50,51 @@ func (a listVersionsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { } for _, s := range a.params.Sort() { - cursorZero := a.params.Cursor().IsZero() - cursorCode := a.params.Cursor().Code() - switch s { case domain.VersionSortCodeASC: - if !cursorZero { - q = q.Where("version.code >= ?", cursorCode) - } - q = q.Order("version.code ASC") case domain.VersionSortCodeDESC: - if !cursorZero { - q = q.Where("version.code <= ?", cursorCode) - } - q = q.Order("version.code DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } - return q.Limit(a.params.Limit() + 1) + return q.Limit(a.params.Limit() + 1).Apply(a.applyCursor) +} + +func (a listVersionsParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { + cursor := a.params.Cursor() + + if cursor.IsZero() { + return q + } + + sort := a.params.Sort() + cursorApplier := cursorPaginationApplier{ + data: make([]cursorPaginationApplierDataElement, 0, len(sort)), + } + + for _, s := range sort { + var el cursorPaginationApplierDataElement + + switch s { + case domain.VersionSortCodeASC: + el.value = cursor.Code() + el.unique = true + el.column = "version.code" + el.direction = sortDirectionASC + case domain.VersionSortCodeDESC: + el.value = cursor.Code() + el.unique = true + el.column = "version.code" + el.direction = sortDirectionDESC + default: + return q.Err(errInvalidSortValue) + } + + cursorApplier.data = append(cursorApplier.data, el) + } + + return q.Apply(cursorApplier.apply) } diff --git a/internal/adapter/repository_bun_village.go b/internal/adapter/repository_bun_village.go index 0cd0b6c..2b83ed0 100644 --- a/internal/adapter/repository_bun_village.go +++ b/internal/adapter/repository_bun_village.go @@ -135,7 +135,7 @@ func (a listVillagesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.VillageSortServerKeyDESC: q = q.Order("village.server_key DESC") default: - return q.Err(errUnsupportedSortValue) + return q.Err(errInvalidSortValue) } } diff --git a/internal/adapter/repository_player_test.go b/internal/adapter/repository_player_test.go index dcc827b..4490ec9 100644 --- a/internal/adapter/repository_player_test.go +++ b/internal/adapter/repository_player_test.go @@ -159,6 +159,180 @@ func testPlayerRepository(t *testing.T, newRepos func(t *testing.T) repositories require.NoError(t, err) }, }, + { + name: "OK: sort=[scoreAtt DESC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{ + domain.PlayerSortODScoreAttDESC, + domain.PlayerSortServerKeyASC, + domain.PlayerSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + players := res.Players() + assert.NotEmpty(t, len(players)) + assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int { + return cmp.Or( + cmp.Compare(a.OD().ScoreAtt(), b.OD().ScoreAtt())*-1, + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: sort=[scoreDef DESC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{ + domain.PlayerSortODScoreDefDESC, + domain.PlayerSortServerKeyASC, + domain.PlayerSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + players := res.Players() + assert.NotEmpty(t, len(players)) + assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int { + return cmp.Or( + cmp.Compare(a.OD().ScoreDef(), b.OD().ScoreDef())*-1, + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: sort=[scoreTotal DESC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{ + domain.PlayerSortODScoreTotalDESC, + domain.PlayerSortServerKeyASC, + domain.PlayerSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + players := res.Players() + assert.NotEmpty(t, len(players)) + assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int { + return cmp.Or( + cmp.Compare(a.OD().ScoreTotal(), b.OD().ScoreTotal())*-1, + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: sort=[points DESC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{ + domain.PlayerSortPointsDESC, + domain.PlayerSortServerKeyASC, + domain.PlayerSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + players := res.Players() + assert.NotEmpty(t, len(players)) + assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int { + return cmp.Or( + cmp.Compare(a.Points(), b.Points())*-1, + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: sort=[deletedAt ASC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{ + domain.PlayerSortDeletedAtASC, + domain.PlayerSortServerKeyASC, + domain.PlayerSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + players := res.Players() + assert.NotEmpty(t, len(players)) + assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int { + return cmp.Or( + a.DeletedAt().Compare(b.DeletedAt()), + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, + { + name: "OK: sort=[deletedAt DESC, serverKey ASC, id ASC]", + params: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{ + domain.PlayerSortDeletedAtDESC, + domain.PlayerSortServerKeyASC, + domain.PlayerSortIDASC, + })) + return params + }, + assertResult: func(t *testing.T, _ domain.ListPlayersParams, res domain.ListPlayersResult) { + t.Helper() + players := res.Players() + assert.NotEmpty(t, len(players)) + assert.True(t, slices.IsSortedFunc(players, func(a, b domain.Player) int { + return cmp.Or( + a.DeletedAt().Compare(b.DeletedAt())*-1, + cmp.Compare(a.ServerKey(), b.ServerKey()), + cmp.Compare(a.ID(), b.ID()), + ) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, { name: "OK: ids serverKeys", params: func(t *testing.T) domain.ListPlayersParams { diff --git a/internal/domain/player.go b/internal/domain/player.go index 2604ab6..870111d 100644 --- a/internal/domain/player.go +++ b/internal/domain/player.go @@ -6,6 +6,7 @@ import ( "math" "net/url" "slices" + "strings" "time" ) @@ -371,8 +372,48 @@ const ( PlayerSortIDDESC PlayerSortServerKeyASC PlayerSortServerKeyDESC + PlayerSortODScoreAttASC + PlayerSortODScoreAttDESC + PlayerSortODScoreDefASC + PlayerSortODScoreDefDESC + PlayerSortODScoreTotalASC + PlayerSortODScoreTotalDESC + PlayerSortPointsASC + PlayerSortPointsDESC + PlayerSortDeletedAtASC + PlayerSortDeletedAtDESC ) +//nolint:gocyclo +func newPlayerSortFromString(s string) (PlayerSort, error) { + switch strings.ToLower(s) { + case "odscoreatt:asc": + return PlayerSortODScoreAttASC, nil + case "odscoreatt:desc": + return PlayerSortODScoreAttDESC, nil + case "odscoredef:asc": + return PlayerSortODScoreDefASC, nil + case "odscoredef:desc": + return PlayerSortODScoreDefDESC, nil + case "odscoretotal:asc": + return PlayerSortODScoreTotalASC, nil + case "odscoretotal:desc": + return PlayerSortODScoreTotalDESC, nil + case "points:asc": + return PlayerSortPointsASC, nil + case "points:desc": + return PlayerSortPointsDESC, nil + case "deletedat:asc": + return PlayerSortDeletedAtASC, nil + case "deletedat:desc": + return PlayerSortDeletedAtDESC, nil + default: + return 0, UnsupportedSortStringError{ + Sort: s, + } + } +} + type PlayerCursor struct { id int serverKey string @@ -628,7 +669,7 @@ func (params *ListPlayersParams) Sort() []PlayerSort { const ( playerSortMinLength = 1 - playerSortMaxLength = 2 + playerSortMaxLength = 3 ) func (params *ListPlayersParams) SetSort(sort []PlayerSort) error { @@ -645,6 +686,31 @@ func (params *ListPlayersParams) SetSort(sort []PlayerSort) error { return nil } +func (params *ListPlayersParams) PrependSortString(sort []string) error { + if err := validateSliceLen(sort, playerSortMinLength, max(playerSortMaxLength-len(params.sort), 0)); err != nil { + return ValidationError{ + Model: listPlayersParamsModelName, + Field: "sort", + Err: err, + } + } + + for i := len(sort) - 1; i >= 0; i-- { + converted, err := newPlayerSortFromString(sort[i]) + if err != nil { + return SliceElementValidationError{ + Model: listPlayersParamsModelName, + Field: "sort", + Index: i, + Err: err, + } + } + params.sort = append([]PlayerSort{converted}, params.sort...) + } + + return nil +} + func (params *ListPlayersParams) Cursor() PlayerCursor { return params.cursor } diff --git a/internal/domain/player_test.go b/internal/domain/player_test.go index b039099..83ca62a 100644 --- a/internal/domain/player_test.go +++ b/internal/domain/player_test.go @@ -490,6 +490,7 @@ func TestListPlayersParams_SetSort(t *testing.T) { name: "OK", args: args{ sort: []domain.PlayerSort{ + domain.PlayerSortPointsASC, domain.PlayerSortIDASC, domain.PlayerSortServerKeyASC, }, @@ -505,18 +506,19 @@ func TestListPlayersParams_SetSort(t *testing.T) { Field: "sort", Err: domain.LenOutOfRangeError{ Min: 1, - Max: 2, + Max: 3, Current: 0, }, }, }, { - name: "ERR: len(sort) > 2", + name: "ERR: len(sort) > 3", args: args{ sort: []domain.PlayerSort{ + domain.PlayerSortDeletedAtDESC, + domain.PlayerSortPointsASC, domain.PlayerSortIDASC, domain.PlayerSortServerKeyASC, - domain.PlayerSortServerKeyDESC, }, }, expectedErr: domain.ValidationError{ @@ -524,8 +526,8 @@ func TestListPlayersParams_SetSort(t *testing.T) { Field: "sort", Err: domain.LenOutOfRangeError{ Min: 1, - Max: 2, - Current: 3, + Max: 3, + Current: 4, }, }, }, @@ -546,6 +548,181 @@ func TestListPlayersParams_SetSort(t *testing.T) { } } +func TestListPlayersParams_PrependSortString(t *testing.T) { + t.Parallel() + + defaultNewParams := func(t *testing.T) domain.ListPlayersParams { + t.Helper() + return domain.ListPlayersParams{} + } + + type args struct { + sort []string + } + + tests := []struct { + name string + newParams func(t *testing.T) domain.ListPlayersParams + args args + expectedSort []domain.PlayerSort + expectedErr error + }{ + { + name: "OK: [odScoreAtt:ASC, odScoreDef:ASC, odScoreTotal:ASC]", + args: args{ + sort: []string{ + "odScoreAtt:ASC", + "odScoreDef:ASC", + "odScoreTotal:ASC", + }, + }, + expectedSort: []domain.PlayerSort{ + domain.PlayerSortODScoreAttASC, + domain.PlayerSortODScoreDefASC, + domain.PlayerSortODScoreTotalASC, + }, + }, + { + name: "OK: [odScoreAtt:DESC, odScoreDef:DESC, odScoreTotal:DESC]", + args: args{ + sort: []string{ + "odScoreAtt:DESC", + "odScoreDef:DESC", + "odScoreTotal:DESC", + }, + }, + expectedSort: []domain.PlayerSort{ + domain.PlayerSortODScoreAttDESC, + domain.PlayerSortODScoreDefDESC, + domain.PlayerSortODScoreTotalDESC, + }, + }, + { + name: "OK: [points:ASC, deletedAt:ASC]", + args: args{ + sort: []string{ + "points:ASC", + "deletedAt:ASC", + }, + }, + expectedSort: []domain.PlayerSort{ + domain.PlayerSortPointsASC, + domain.PlayerSortDeletedAtASC, + }, + }, + { + name: "OK: [points:DESC, deletedAt:DESC]", + args: args{ + sort: []string{ + "points:DESC", + "deletedAt:DESC", + }, + }, + expectedSort: []domain.PlayerSort{ + domain.PlayerSortPointsDESC, + domain.PlayerSortDeletedAtDESC, + }, + }, + { + name: "ERR: len(sort) < 1", + args: args{ + sort: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayersParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 3, + Current: 0, + }, + }, + }, + { + name: "ERR: len(sort) > 3", + args: args{ + sort: []string{ + "odScoreAtt:ASC", + "odScoreDef:ASC", + "odScoreTotal:ASC", + "points:ASC", + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayersParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 3, + Current: 4, + }, + }, + }, + { + name: "ERR: custom params + len(sort) > 2", + newParams: func(t *testing.T) domain.ListPlayersParams { + t.Helper() + params := domain.NewListPlayersParams() + require.NoError(t, params.SetSort([]domain.PlayerSort{domain.PlayerSortIDASC})) + return params + }, + args: args{ + sort: []string{ + "odScoreAtt:ASC", + "odScoreDef:ASC", + "odScoreTotal:ASC", + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListPlayersParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 2, + Current: 3, + }, + }, + }, + { + name: "ERR: unsupported sort string", + newParams: defaultNewParams, + args: args{ + sort: []string{ + "odScoreAtt:ASC", + "odScoreDef:ASC", + "odScoreTotal:", + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListPlayersParams", + Field: "sort", + Index: 2, + Err: domain.UnsupportedSortStringError{ + Sort: "odScoreTotal:", + }, + }, + }, + } + + 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 TestListPlayersParams_SetEncodedCursor(t *testing.T) { t.Parallel() diff --git a/internal/port/handler_http_api_player.go b/internal/port/handler_http_api_player.go index bdc6b50..fa59579 100644 --- a/internal/port/handler_http_api_player.go +++ b/internal/port/handler_http_api_player.go @@ -8,6 +8,7 @@ import ( "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" ) +//nolint:gocyclo func (h *apiHTTPHandler) ListPlayers( w http.ResponseWriter, r *http.Request, @@ -22,6 +23,13 @@ func (h *apiHTTPHandler) ListPlayers( return } + if params.Sort != nil { + if err := domainParams.PrependSortString(*params.Sort); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListPlayersErrorPath).render(w, r, err) + return + } + } + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { h.errorRenderer.withErrorPathFormatter(formatListPlayersErrorPath).render(w, r, err) return diff --git a/internal/port/handler_http_api_player_test.go b/internal/port/handler_http_api_player_test.go index 118879a..dade70e 100644 --- a/internal/port/handler_http_api_player_test.go +++ b/internal/port/handler_http_api_player_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" @@ -111,6 +112,70 @@ func TestListPlayers(t *testing.T) { assert.Len(t, body.Data, limit) }, }, + { + name: "OK: sort=[deletedAt:DESC,points:ASC]", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Add("sort", "deletedAt:DESC") + q.Add("sort", "points:ASC") + 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 { + var aDeletedAt time.Time + if a.DeletedAt != nil { + aDeletedAt = *a.DeletedAt + } + + var bDeletedAt time.Time + if b.DeletedAt != nil { + bDeletedAt = *b.DeletedAt + } + + return cmp.Or( + aDeletedAt.Compare(bDeletedAt)*-1, + cmp.Compare(a.Points, b.Points), + cmp.Compare(a.Id, b.Id), + ) + })) + }, + }, + { + name: "OK: sort=[odScoreAtt:DESC]", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("sort", "odScoreAtt: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( + cmp.Compare(a.OpponentsDefeated.ScoreAtt, b.OpponentsDefeated.ScoreAtt)*-1, + cmp.Compare(a.Id, b.Id), + ) + })) + }, + }, { name: "OK: deleted=false", reqModifier: func(t *testing.T, req *http.Request) { @@ -127,8 +192,8 @@ func TestListPlayers(t *testing.T) { // body body := decodeJSON[apimodel.ListPlayersResponse](t, resp.Body) assert.NotZero(t, body.Data) - for _, tr := range body.Data { - assert.Nil(t, tr.DeletedAt) + for _, p := range body.Data { + assert.Nil(t, p.DeletedAt) } }, }, @@ -148,8 +213,8 @@ func TestListPlayers(t *testing.T) { // body body := decodeJSON[apimodel.ListPlayersResponse](t, resp.Body) assert.NotZero(t, body.Data) - for _, tr := range body.Data { - assert.NotNil(t, tr.DeletedAt) + for _, p := range body.Data { + assert.NotNil(t, p.DeletedAt) } }, }, @@ -354,6 +419,77 @@ func TestListPlayers(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", "odScoreAtt:DESC") + q.Add("sort", "odScoreAtt:ASC") + q.Add("sort", "points: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", "odScoreAtt:ASC") + q.Add("sort", "test:DESC") + 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"][1], + } + 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", "1"}, + }, + }, + }, body) + }, + }, { name: "ERR: deleted is not a valid boolean", reqModifier: func(t *testing.T, req *http.Request) {