mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
feat(keys): sort by current concurrency
This commit is contained in:
@@ -388,10 +388,9 @@ func (r *apiKeyRepository) deleteWithAudit(ctx context.Context, exec *dbent.Clie
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
func (r *apiKeyRepository) apiKeyListByUserIDQuery(userID int64, filters service.APIKeyListFilters) *dbent.APIKeyQuery {
|
||||||
q := r.activeQuery().Where(apikey.UserIDEQ(userID))
|
q := r.activeQuery().Where(apikey.UserIDEQ(userID))
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
if filters.Search != "" {
|
if filters.Search != "" {
|
||||||
q = q.Where(apikey.Or(
|
q = q.Where(apikey.Or(
|
||||||
apikey.NameContainsFold(filters.Search),
|
apikey.NameContainsFold(filters.Search),
|
||||||
@@ -409,6 +408,12 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||||
|
q := r.apiKeyListByUserIDQuery(userID, filters)
|
||||||
|
|
||||||
total, err := q.Count(ctx)
|
total, err := q.Count(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -435,6 +440,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
|
|||||||
return outKeys, paginationResultFromTotal(int64(total), params), nil
|
return outKeys, paginationResultFromTotal(int64(total), params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) ListAllByUserID(ctx context.Context, userID int64, filters service.APIKeyListFilters) ([]service.APIKey, error) {
|
||||||
|
keys, err := r.apiKeyListByUserIDQuery(userID, filters).
|
||||||
|
WithGroup().
|
||||||
|
Order(dbent.Asc(apikey.FieldID)).
|
||||||
|
All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outKeys := make([]service.APIKey, 0, len(keys))
|
||||||
|
for i := range keys {
|
||||||
|
outKeys = append(outKeys, *apiKeyEntityToService(keys[i]))
|
||||||
|
}
|
||||||
|
return outKeys, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
if len(apiKeyIDs) == 0 {
|
if len(apiKeyIDs) == 0 {
|
||||||
return []int64{}, nil
|
return []int64{}, nil
|
||||||
@@ -504,14 +525,24 @@ func apiKeyListOrder(params pagination.PaginationParams) []func(*entsql.Selector
|
|||||||
field = apikey.FieldLastUsedAt
|
field = apikey.FieldLastUsedAt
|
||||||
case "created_at":
|
case "created_at":
|
||||||
field = apikey.FieldCreatedAt
|
field = apikey.FieldCreatedAt
|
||||||
|
case "id":
|
||||||
|
field = apikey.FieldID
|
||||||
default:
|
default:
|
||||||
field = apikey.FieldID
|
field = apikey.FieldID
|
||||||
}
|
}
|
||||||
|
|
||||||
if sortOrder == pagination.SortOrderAsc {
|
if sortOrder == pagination.SortOrderAsc {
|
||||||
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(apikey.FieldID)}
|
orders := []func(*entsql.Selector){dbent.Asc(field)}
|
||||||
|
if field != apikey.FieldID {
|
||||||
|
orders = append(orders, dbent.Asc(apikey.FieldID))
|
||||||
|
}
|
||||||
|
return orders
|
||||||
}
|
}
|
||||||
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(apikey.FieldID)}
|
orders := []func(*entsql.Selector){dbent.Desc(field)}
|
||||||
|
if field != apikey.FieldID {
|
||||||
|
orders = append(orders, dbent.Desc(apikey.FieldID))
|
||||||
|
}
|
||||||
|
return orders
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAPIKeys searches API keys by user ID and/or keyword (name)
|
// SearchAPIKeys searches API keys by user ID and/or keyword (name)
|
||||||
|
|||||||
@@ -23,3 +23,20 @@ func (s *APIKeyRepoSuite) TestListByUserID_SortByNameAsc() {
|
|||||||
s.Require().Equal("a-key", keys[0].Name)
|
s.Require().Equal("a-key", keys[0].Name)
|
||||||
s.Require().Equal("z-key", keys[1].Name)
|
s.Require().Equal("z-key", keys[1].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyRepoSuite) TestListByUserID_SortByID() {
|
||||||
|
user := s.mustCreateUser("sort-id@example.com")
|
||||||
|
first := s.mustCreateApiKey(user.ID, "sk-id-a", "a-key", nil)
|
||||||
|
second := s.mustCreateApiKey(user.ID, "sk-id-b", "b-key", nil)
|
||||||
|
|
||||||
|
keys, _, err := s.repo.ListByUserID(s.ctx, user.ID, pagination.PaginationParams{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
SortBy: "id",
|
||||||
|
SortOrder: "desc",
|
||||||
|
}, service.APIKeyListFilters{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Len(keys, 2)
|
||||||
|
s.Require().Equal(second.ID, keys[0].ID)
|
||||||
|
s.Require().Equal(first.ID, keys[1].ID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -40,8 +41,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
apiKeyMaxErrorsPerHour = 20
|
apiKeyMaxErrorsPerHour = 20
|
||||||
apiKeyLastUsedMinTouch = 30 * time.Second
|
apiKeyLastUsedMinTouch = 30 * time.Second
|
||||||
|
apiKeySortCurrentConcurrency = "current_concurrency"
|
||||||
// DB 写失败后的短退避,避免请求路径持续同步重试造成写风暴与高延迟。
|
// DB 写失败后的短退避,避免请求路径持续同步重试造成写风暴与高延迟。
|
||||||
apiKeyLastUsedFailBackoff = 5 * time.Second
|
apiKeyLastUsedFailBackoff = 5 * time.Second
|
||||||
)
|
)
|
||||||
@@ -82,6 +84,10 @@ type APIKeyRepository interface {
|
|||||||
GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error)
|
GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiKeyAllByUserIDLister interface {
|
||||||
|
ListAllByUserID(ctx context.Context, userID int64, filters APIKeyListFilters) ([]APIKey, error)
|
||||||
|
}
|
||||||
|
|
||||||
// APIKeyRateLimitData holds rate limit usage and window state for an API key.
|
// APIKeyRateLimitData holds rate limit usage and window state for an API key.
|
||||||
type APIKeyRateLimitData struct {
|
type APIKeyRateLimitData struct {
|
||||||
Usage5h float64
|
Usage5h float64
|
||||||
@@ -437,6 +443,10 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
|
|||||||
|
|
||||||
// List 获取用户的API Key列表
|
// List 获取用户的API Key列表
|
||||||
func (s *APIKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
func (s *APIKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||||
|
if normalizedAPIKeySortBy(params.SortBy) == apiKeySortCurrentConcurrency {
|
||||||
|
return s.listByCurrentConcurrency(ctx, userID, params, filters)
|
||||||
|
}
|
||||||
|
|
||||||
keys, pagination, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, filters)
|
keys, pagination, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("list api keys: %w", err)
|
return nil, nil, fmt.Errorf("list api keys: %w", err)
|
||||||
@@ -445,6 +455,75 @@ func (s *APIKeyService) List(ctx context.Context, userID int64, params paginatio
|
|||||||
return keys, pagination, nil
|
return keys, pagination, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) listByCurrentConcurrency(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||||
|
repo, ok := s.apiKeyRepo.(apiKeyAllByUserIDLister)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("list api keys by current concurrency: repository does not support unpaginated API key listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := repo.ListAllByUserID(ctx, userID, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list api keys: %w", err)
|
||||||
|
}
|
||||||
|
s.fillCurrentConcurrency(ctx, keys)
|
||||||
|
sortAPIKeysByCurrentConcurrency(keys, params.NormalizedSortOrder(pagination.SortOrderDesc))
|
||||||
|
return paginateAPIKeys(keys, params), apiKeyPaginationResult(int64(len(keys)), params), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedAPIKeySortBy(sortBy string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(sortBy))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAPIKeysByCurrentConcurrency(keys []APIKey, sortOrder string) {
|
||||||
|
desc := sortOrder != pagination.SortOrderAsc
|
||||||
|
sort.SliceStable(keys, func(i, j int) bool {
|
||||||
|
if keys[i].CurrentConcurrency == keys[j].CurrentConcurrency {
|
||||||
|
if desc {
|
||||||
|
return keys[i].ID > keys[j].ID
|
||||||
|
}
|
||||||
|
return keys[i].ID < keys[j].ID
|
||||||
|
}
|
||||||
|
if desc {
|
||||||
|
return keys[i].CurrentConcurrency > keys[j].CurrentConcurrency
|
||||||
|
}
|
||||||
|
return keys[i].CurrentConcurrency < keys[j].CurrentConcurrency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginateAPIKeys(keys []APIKey, params pagination.PaginationParams) []APIKey {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return []APIKey{}
|
||||||
|
}
|
||||||
|
limit := params.Limit()
|
||||||
|
page := params.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
if offset >= len(keys) {
|
||||||
|
return []APIKey{}
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(keys) {
|
||||||
|
end = len(keys)
|
||||||
|
}
|
||||||
|
return keys[offset:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyPaginationResult(total int64, params pagination.PaginationParams) *pagination.PaginationResult {
|
||||||
|
limit := params.Limit()
|
||||||
|
pages := int(total) / limit
|
||||||
|
if int(total)%limit > 0 {
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return &pagination.PaginationResult{
|
||||||
|
Total: total,
|
||||||
|
Page: params.Page,
|
||||||
|
PageSize: limit,
|
||||||
|
Pages: pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) fillCurrentConcurrency(ctx context.Context, keys []APIKey) {
|
func (s *APIKeyService) fillCurrentConcurrency(ctx context.Context, keys []APIKey) {
|
||||||
if s == nil || s.concurrencyService == nil || len(keys) == 0 {
|
if s == nil || s.concurrencyService == nil || len(keys) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,20 +25,26 @@ import (
|
|||||||
// - deleteErr: 模拟 Delete 返回的错误
|
// - deleteErr: 模拟 Delete 返回的错误
|
||||||
// - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证
|
// - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证
|
||||||
type apiKeyRepoStub struct {
|
type apiKeyRepoStub struct {
|
||||||
apiKey *APIKey // GetKeyAndOwnerID 的返回值
|
apiKey *APIKey // GetKeyAndOwnerID 的返回值
|
||||||
getByIDErr error // GetKeyAndOwnerID 的错误返回值
|
getByIDErr error // GetKeyAndOwnerID 的错误返回值
|
||||||
deleteErr error // Delete 的错误返回值
|
deleteErr error // Delete 的错误返回值
|
||||||
updateErr error // Update 的错误返回值
|
updateErr error // Update 的错误返回值
|
||||||
deletedIDs []int64 // 记录已删除的 API Key ID 列表
|
deletedIDs []int64 // 记录已删除的 API Key ID 列表
|
||||||
updatedKeys []APIKey
|
updatedKeys []APIKey
|
||||||
allowListByUserID bool
|
allowListByUserID bool
|
||||||
listByUserIDKeys []APIKey
|
listByUserIDKeys []APIKey
|
||||||
listByUserIDErr error
|
listByUserIDErr error
|
||||||
listByUserIDCalls []int64
|
listByUserIDCalls []int64
|
||||||
listByUserIDParams []pagination.PaginationParams
|
listByUserIDParams []pagination.PaginationParams
|
||||||
updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
|
listByUserIDFilters []APIKeyListFilters
|
||||||
touchedIDs []int64
|
allowListAllByUserID bool
|
||||||
touchedUsedAts []time.Time
|
listAllByUserIDKeys []APIKey
|
||||||
|
listAllByUserIDErr error
|
||||||
|
listAllByUserIDCalls []int64
|
||||||
|
listAllByUserIDFilters []APIKeyListFilters
|
||||||
|
updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
|
||||||
|
touchedIDs []int64
|
||||||
|
touchedUsedAts []time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题
|
// 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题
|
||||||
@@ -103,6 +110,7 @@ func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params
|
|||||||
}
|
}
|
||||||
s.listByUserIDCalls = append(s.listByUserIDCalls, userID)
|
s.listByUserIDCalls = append(s.listByUserIDCalls, userID)
|
||||||
s.listByUserIDParams = append(s.listByUserIDParams, params)
|
s.listByUserIDParams = append(s.listByUserIDParams, params)
|
||||||
|
s.listByUserIDFilters = append(s.listByUserIDFilters, filters)
|
||||||
if s.listByUserIDErr != nil {
|
if s.listByUserIDErr != nil {
|
||||||
return nil, nil, s.listByUserIDErr
|
return nil, nil, s.listByUserIDErr
|
||||||
}
|
}
|
||||||
@@ -115,6 +123,51 @@ func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *apiKeyRepoStub) ListAllByUserID(ctx context.Context, userID int64, filters APIKeyListFilters) ([]APIKey, error) {
|
||||||
|
if !s.allowListAllByUserID {
|
||||||
|
panic("unexpected ListAllByUserID call")
|
||||||
|
}
|
||||||
|
s.listAllByUserIDCalls = append(s.listAllByUserIDCalls, userID)
|
||||||
|
s.listAllByUserIDFilters = append(s.listAllByUserIDFilters, filters)
|
||||||
|
if s.listAllByUserIDErr != nil {
|
||||||
|
return nil, s.listAllByUserIDErr
|
||||||
|
}
|
||||||
|
source := s.listByUserIDKeys
|
||||||
|
if s.listAllByUserIDKeys != nil {
|
||||||
|
source = s.listAllByUserIDKeys
|
||||||
|
}
|
||||||
|
return filterAPIKeyStubKeys(userID, source, filters), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterAPIKeyStubKeys(userID int64, keys []APIKey, filters APIKeyListFilters) []APIKey {
|
||||||
|
result := make([]APIKey, 0, len(keys))
|
||||||
|
search := strings.ToLower(filters.Search)
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.UserID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if search != "" &&
|
||||||
|
!strings.Contains(strings.ToLower(key.Name), search) &&
|
||||||
|
!strings.Contains(strings.ToLower(key.Key), search) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filters.Status != "" && key.Status != filters.Status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filters.GroupID != nil {
|
||||||
|
if *filters.GroupID == 0 {
|
||||||
|
if key.GroupID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if key.GroupID == nil || *key.GroupID != *filters.GroupID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (s *apiKeyRepoStub) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
func (s *apiKeyRepoStub) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
panic("unexpected VerifyOwnership call")
|
panic("unexpected VerifyOwnership call")
|
||||||
}
|
}
|
||||||
@@ -320,6 +373,96 @@ func TestAPIKeyService_List_FillsCurrentConcurrency(t *testing.T) {
|
|||||||
require.Equal(t, 0, keys[1].CurrentConcurrency)
|
require.Equal(t, 0, keys[1].CurrentConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyService_List_SortByCurrentConcurrency(t *testing.T) {
|
||||||
|
groupID := int64(42)
|
||||||
|
keys := []APIKey{
|
||||||
|
{ID: 1, UserID: 7, Key: "sk-target-1", Name: "target-one", GroupID: &groupID, Status: StatusActive},
|
||||||
|
{ID: 2, UserID: 7, Key: "sk-target-2", Name: "target-two", GroupID: &groupID, Status: StatusActive},
|
||||||
|
{ID: 3, UserID: 7, Key: "sk-target-3", Name: "target-three", GroupID: &groupID, Status: StatusActive},
|
||||||
|
{ID: 4, UserID: 7, Key: "sk-target-4", Name: "target-four", GroupID: &groupID, Status: StatusActive},
|
||||||
|
{ID: 9, UserID: 7, Key: "sk-target-9", Name: "target-inactive", GroupID: &groupID, Status: StatusDisabled},
|
||||||
|
{ID: 10, UserID: 7, Key: "sk-other-10", Name: "other", GroupID: &groupID, Status: StatusActive},
|
||||||
|
{ID: 11, UserID: 7, Key: "sk-target-11", Name: "target-no-group", Status: StatusActive},
|
||||||
|
{ID: 12, UserID: 8, Key: "sk-target-12", Name: "target-other-user", GroupID: &groupID, Status: StatusActive},
|
||||||
|
}
|
||||||
|
filters := APIKeyListFilters{
|
||||||
|
Search: "target",
|
||||||
|
Status: StatusActive,
|
||||||
|
GroupID: &groupID,
|
||||||
|
}
|
||||||
|
repo := &apiKeyRepoStub{
|
||||||
|
allowListAllByUserID: true,
|
||||||
|
listAllByUserIDKeys: keys,
|
||||||
|
}
|
||||||
|
concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{
|
||||||
|
apiKeyConcurrency: map[int64]int{
|
||||||
|
1: 5,
|
||||||
|
2: 5,
|
||||||
|
3: 2,
|
||||||
|
4: 8,
|
||||||
|
9: 99,
|
||||||
|
10: 99,
|
||||||
|
11: 99,
|
||||||
|
12: 99,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency}
|
||||||
|
|
||||||
|
got, page, err := svc.List(context.Background(), 7, pagination.PaginationParams{
|
||||||
|
Page: 2,
|
||||||
|
PageSize: 2,
|
||||||
|
SortBy: "current_concurrency",
|
||||||
|
SortOrder: "desc",
|
||||||
|
}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []int64{1, 3}, apiKeyTestIDs(got))
|
||||||
|
require.Equal(t, int64(4), page.Total)
|
||||||
|
require.Equal(t, 2, page.Page)
|
||||||
|
require.Equal(t, 2, page.PageSize)
|
||||||
|
require.Equal(t, 2, page.Pages)
|
||||||
|
require.Empty(t, repo.listByUserIDCalls)
|
||||||
|
require.Equal(t, []int64{7}, repo.listAllByUserIDCalls)
|
||||||
|
require.Len(t, repo.listAllByUserIDFilters, 1)
|
||||||
|
require.Equal(t, filters.Search, repo.listAllByUserIDFilters[0].Search)
|
||||||
|
require.Equal(t, filters.Status, repo.listAllByUserIDFilters[0].Status)
|
||||||
|
require.NotNil(t, repo.listAllByUserIDFilters[0].GroupID)
|
||||||
|
require.Equal(t, groupID, *repo.listAllByUserIDFilters[0].GroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyService_List_SortByCurrentConcurrencyAscTiesByID(t *testing.T) {
|
||||||
|
repo := &apiKeyRepoStub{
|
||||||
|
allowListAllByUserID: true,
|
||||||
|
listAllByUserIDKeys: []APIKey{
|
||||||
|
{ID: 1, UserID: 7, Key: "sk-1", Name: "one", Status: StatusActive},
|
||||||
|
{ID: 2, UserID: 7, Key: "sk-2", Name: "two", Status: StatusActive},
|
||||||
|
{ID: 3, UserID: 7, Key: "sk-3", Name: "three", Status: StatusActive},
|
||||||
|
{ID: 4, UserID: 7, Key: "sk-4", Name: "four", Status: StatusActive},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{
|
||||||
|
apiKeyConcurrency: map[int64]int{1: 5, 2: 5, 3: 2, 4: 8},
|
||||||
|
})
|
||||||
|
svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency}
|
||||||
|
|
||||||
|
got, page, err := svc.List(context.Background(), 7, pagination.PaginationParams{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 4,
|
||||||
|
SortBy: "current_concurrency",
|
||||||
|
SortOrder: "asc",
|
||||||
|
}, APIKeyListFilters{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []int64{3, 1, 2, 4}, apiKeyTestIDs(got))
|
||||||
|
require.Equal(t, 4, page.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyTestIDs(keys []APIKey) []int64 {
|
||||||
|
ids := make([]int64, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
ids = append(ids, key.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIKeyService_GetByID_FillsCurrentConcurrency(t *testing.T) {
|
func TestAPIKeyService_GetByID_FillsCurrentConcurrency(t *testing.T) {
|
||||||
repo := &apiKeyRepoStub{
|
repo := &apiKeyRepoStub{
|
||||||
apiKey: &APIKey{ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"},
|
apiKey: &APIKey{ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"},
|
||||||
|
|||||||
@@ -1168,7 +1168,7 @@ const allColumns = computed<Column[]>(() => [
|
|||||||
{ key: 'name', label: t('common.name'), sortable: true },
|
{ key: 'name', label: t('common.name'), sortable: true },
|
||||||
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
||||||
{ key: 'group', label: t('keys.group'), sortable: false },
|
{ key: 'group', label: t('keys.group'), sortable: false },
|
||||||
{ key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: false },
|
{ key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: true },
|
||||||
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
||||||
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
|
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
|
||||||
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
||||||
|
|||||||
@@ -149,11 +149,16 @@ const TablePageLayoutStub = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DataTableStub = {
|
const DataTableStub = {
|
||||||
|
name: 'DataTable',
|
||||||
props: ['columns', 'data'],
|
props: ['columns', 'data'],
|
||||||
emits: ['sort'],
|
emits: ['sort'],
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
|
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
|
||||||
|
<div data-test="columns-meta">{{ JSON.stringify(columns.map((col) => ({ key: col.key, sortable: !!col.sortable }))) }}</div>
|
||||||
|
<button data-test="sort-current-concurrency" @click="$emit('sort', 'current_concurrency', 'asc')">
|
||||||
|
Sort Current Concurrency
|
||||||
|
</button>
|
||||||
<div v-for="row in data" :key="row.id">
|
<div v-for="row in data" :key="row.id">
|
||||||
<slot name="cell-name" :value="row.name" :row="row" />
|
<slot name="cell-name" :value="row.name" :row="row" />
|
||||||
<div data-test="current-concurrency">
|
<div data-test="current-concurrency">
|
||||||
@@ -166,17 +171,30 @@ const DataTableStub = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SelectStub = {
|
const SelectStub = {
|
||||||
|
name: 'Select',
|
||||||
props: ['modelValue', 'options'],
|
props: ['modelValue', 'options'],
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"></select>',
|
template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"></select>',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchInputStub = {
|
const SearchInputStub = {
|
||||||
|
name: 'SearchInput',
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
emits: ['update:modelValue', 'search'],
|
emits: ['update:modelValue', 'search'],
|
||||||
template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PaginationStub = {
|
||||||
|
name: 'Pagination',
|
||||||
|
props: ['page', 'total', 'pageSize'],
|
||||||
|
emits: ['update:page', 'update:pageSize'],
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<button data-test="page-size-50" @click="$emit('update:pageSize', 50)">50</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
const IconStub = {
|
const IconStub = {
|
||||||
props: ['name'],
|
props: ['name'],
|
||||||
template: '<span data-test="icon">{{ name }}</span>',
|
template: '<span data-test="icon">{{ name }}</span>',
|
||||||
@@ -189,7 +207,7 @@ const mountView = async () => {
|
|||||||
AppLayout: AppLayoutStub,
|
AppLayout: AppLayoutStub,
|
||||||
TablePageLayout: TablePageLayoutStub,
|
TablePageLayout: TablePageLayoutStub,
|
||||||
DataTable: DataTableStub,
|
DataTable: DataTableStub,
|
||||||
Pagination: true,
|
Pagination: PaginationStub,
|
||||||
BaseDialog: true,
|
BaseDialog: true,
|
||||||
ConfirmDialog: true,
|
ConfirmDialog: true,
|
||||||
EmptyState: true,
|
EmptyState: true,
|
||||||
@@ -212,6 +230,9 @@ const mountView = async () => {
|
|||||||
const visibleColumnKeys = (wrapper: VueWrapper) =>
|
const visibleColumnKeys = (wrapper: VueWrapper) =>
|
||||||
wrapper.get('[data-test="columns"]').text().split(',').filter(Boolean)
|
wrapper.get('[data-test="columns"]').text().split(',').filter(Boolean)
|
||||||
|
|
||||||
|
const visibleColumnMeta = (wrapper: VueWrapper): Array<{ key: string; sortable: boolean }> =>
|
||||||
|
JSON.parse(wrapper.get('[data-test="columns-meta"]').text())
|
||||||
|
|
||||||
const getButtonByText = (wrapper: VueWrapper, text: string) => {
|
const getButtonByText = (wrapper: VueWrapper, text: string) => {
|
||||||
const button = wrapper.findAll('button').find((item) => item.text().includes(text))
|
const button = wrapper.findAll('button').find((item) => item.text().includes(text))
|
||||||
if (!button) {
|
if (!button) {
|
||||||
@@ -317,4 +338,49 @@ describe('user KeysView column settings', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="current-concurrency"]').text()).toBe('3')
|
expect(wrapper.get('[data-test="current-concurrency"]').text()).toBe('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks current concurrency as sortable', async () => {
|
||||||
|
const wrapper = await mountView()
|
||||||
|
|
||||||
|
const currentConcurrencyColumn = visibleColumnMeta(wrapper).find(
|
||||||
|
(column) => column.key === 'current_concurrency'
|
||||||
|
)
|
||||||
|
expect(currentConcurrencyColumn?.sortable).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps filters and selected page size when sorting by current concurrency', async () => {
|
||||||
|
getAvailableGroups.mockResolvedValue([{ id: 42, name: 'OpenAI' }])
|
||||||
|
const wrapper = await mountView()
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="page-size-50"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findComponent({ name: 'SearchInput' }).vm.$emit('update:modelValue', 'target')
|
||||||
|
await wrapper.findComponent({ name: 'SearchInput' }).vm.$emit('search')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const selects = wrapper.findAllComponents({ name: 'Select' })
|
||||||
|
await selects[0].vm.$emit('update:modelValue', 42)
|
||||||
|
await flushPromises()
|
||||||
|
await selects[1].vm.$emit('update:modelValue', 'active')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
listKeys.mockClear()
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="sort-current-concurrency"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listKeys).toHaveBeenLastCalledWith(
|
||||||
|
1,
|
||||||
|
50,
|
||||||
|
{
|
||||||
|
search: 'target',
|
||||||
|
status: 'active',
|
||||||
|
group_id: 42,
|
||||||
|
sort_by: 'current_concurrency',
|
||||||
|
sort_order: 'asc',
|
||||||
|
},
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user