Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
bf3ef2d1
Commit
bf3ef2d1
authored
Apr 21, 2026
by
IanShaw027
Browse files
add admin user last used support
parent
beeab54a
Changes
11
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/dto/mappers.go
View file @
bf3ef2d1
...
...
@@ -68,6 +68,7 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
return
&
AdminUser
{
User
:
*
base
,
Notes
:
u
.
Notes
,
LastUsedAt
:
u
.
LastUsedAt
,
GroupRates
:
u
.
GroupRates
,
}
}
...
...
backend/internal/handler/dto/types.go
View file @
bf3ef2d1
...
...
@@ -36,7 +36,8 @@ type User struct {
type
AdminUser
struct
{
User
Notes
string
`json:"notes"`
Notes
string
`json:"notes"`
LastUsedAt
*
time
.
Time
`json:"last_used_at"`
// GroupRates 用户专属分组倍率配置
// map[groupID]rateMultiplier
GroupRates
map
[
int64
]
float64
`json:"group_rates,omitempty"`
...
...
backend/internal/handler/dto/user_mapper_activity_test.go
View file @
bf3ef2d1
...
...
@@ -13,6 +13,7 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
lastLoginAt
:=
time
.
Date
(
2026
,
time
.
April
,
20
,
10
,
0
,
0
,
0
,
time
.
UTC
)
lastActiveAt
:=
lastLoginAt
.
Add
(
15
*
time
.
Minute
)
lastUsedAt
:=
lastLoginAt
.
Add
(
45
*
time
.
Minute
)
out
:=
UserFromServiceAdmin
(
&
service
.
User
{
ID
:
42
,
...
...
@@ -22,11 +23,14 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
Status
:
service
.
StatusActive
,
LastLoginAt
:
&
lastLoginAt
,
LastActiveAt
:
&
lastActiveAt
,
LastUsedAt
:
&
lastUsedAt
,
})
require
.
NotNil
(
t
,
out
)
require
.
NotNil
(
t
,
out
.
LastLoginAt
)
require
.
NotNil
(
t
,
out
.
LastActiveAt
)
require
.
NotNil
(
t
,
out
.
LastUsedAt
)
require
.
WithinDuration
(
t
,
lastLoginAt
,
*
out
.
LastLoginAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastActiveAt
,
*
out
.
LastActiveAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastUsedAt
,
*
out
.
LastUsedAt
,
time
.
Second
)
}
backend/internal/repository/user_repo.go
View file @
bf3ef2d1
...
...
@@ -299,51 +299,6 @@ func normalizeEmailAuthIdentitySubject(email string) string {
return
normalized
}
func
(
r
*
userRepository
)
GetLatestUsedAtByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
result
:=
make
(
map
[
int64
]
*
time
.
Time
,
len
(
userIDs
))
if
len
(
userIDs
)
==
0
{
return
result
,
nil
}
if
r
.
sql
==
nil
{
return
nil
,
fmt
.
Errorf
(
"sql executor is not configured"
)
}
rows
,
err
:=
r
.
sql
.
QueryContext
(
ctx
,
`
SELECT user_id, MAX(created_at) AS last_used_at
FROM usage_logs
WHERE user_id = ANY($1)
GROUP BY user_id
`
,
pq
.
Array
(
userIDs
))
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
for
rows
.
Next
()
{
var
(
userID
int64
lastUsedAt
time
.
Time
)
if
err
:=
rows
.
Scan
(
&
userID
,
&
lastUsedAt
);
err
!=
nil
{
return
nil
,
err
}
ts
:=
lastUsedAt
.
UTC
()
result
[
userID
]
=
&
ts
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
result
,
nil
}
func
(
r
*
userRepository
)
GetLatestUsedAtByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
*
time
.
Time
,
error
)
{
latestByUserID
,
err
:=
r
.
GetLatestUsedAtByUserIDs
(
ctx
,
[]
int64
{
userID
})
if
err
!=
nil
{
return
nil
,
err
}
return
latestByUserID
[
userID
],
nil
}
func
(
r
*
userRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
affected
,
err
:=
r
.
client
.
User
.
Delete
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
Exec
(
ctx
)
if
err
!=
nil
{
...
...
@@ -469,6 +424,10 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
sortBy
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
params
.
SortBy
))
sortOrder
:=
params
.
NormalizedSortOrder
(
pagination
.
SortOrderDesc
)
if
sortBy
==
"last_used_at"
{
return
userLastUsedAtOrder
(
sortOrder
)
}
var
field
string
defaultField
:=
true
nullsLastField
:=
false
...
...
@@ -530,6 +489,72 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
return
[]
func
(
*
entsql
.
Selector
){
dbent
.
Desc
(
field
),
dbent
.
Desc
(
dbuser
.
FieldID
)}
}
func
(
r
*
userRepository
)
GetLatestUsedAtByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
result
:=
make
(
map
[
int64
]
*
time
.
Time
,
len
(
userIDs
))
if
len
(
userIDs
)
==
0
{
return
result
,
nil
}
if
r
.
sql
==
nil
{
return
nil
,
fmt
.
Errorf
(
"sql executor is not configured"
)
}
const
query
=
`
SELECT user_id, MAX(created_at) AS last_used_at
FROM usage_logs
WHERE user_id = ANY($1)
GROUP BY user_id
`
rows
,
err
:=
r
.
sql
.
QueryContext
(
ctx
,
query
,
pq
.
Array
(
userIDs
))
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
for
rows
.
Next
()
{
var
(
userID
int64
lastUsedAt
time
.
Time
)
if
scanErr
:=
rows
.
Scan
(
&
userID
,
&
lastUsedAt
);
scanErr
!=
nil
{
return
nil
,
scanErr
}
ts
:=
lastUsedAt
.
UTC
()
result
[
userID
]
=
&
ts
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
result
,
nil
}
func
(
r
*
userRepository
)
GetLatestUsedAtByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
*
time
.
Time
,
error
)
{
latestByUserID
,
err
:=
r
.
GetLatestUsedAtByUserIDs
(
ctx
,
[]
int64
{
userID
})
if
err
!=
nil
{
return
nil
,
err
}
return
latestByUserID
[
userID
],
nil
}
func
userLastUsedAtOrder
(
sortOrder
string
)
[]
func
(
*
entsql
.
Selector
)
{
orderExpr
:=
func
(
direction
,
nulls
string
,
tieOrder
func
(
string
)
string
)
func
(
*
entsql
.
Selector
)
{
return
func
(
s
*
entsql
.
Selector
)
{
subquery
:=
fmt
.
Sprintf
(
"(SELECT MAX(created_at) FROM usage_logs WHERE user_id = %s)"
,
s
.
C
(
dbuser
.
FieldID
))
s
.
OrderExpr
(
entsql
.
Expr
(
subquery
+
" "
+
direction
+
" NULLS "
+
nulls
))
s
.
OrderBy
(
tieOrder
(
s
.
C
(
dbuser
.
FieldID
)))
}
}
if
sortOrder
==
pagination
.
SortOrderAsc
{
return
[]
func
(
*
entsql
.
Selector
){
orderExpr
(
"ASC"
,
"FIRST"
,
entsql
.
Asc
),
}
}
return
[]
func
(
*
entsql
.
Selector
){
orderExpr
(
"DESC"
,
"LAST"
,
entsql
.
Desc
),
}
}
// filterUsersByAttributes returns user IDs that match ALL the given attribute filters
func
(
r
*
userRepository
)
filterUsersByAttributes
(
ctx
context
.
Context
,
attrs
map
[
int64
]
string
)
([]
int64
,
error
)
{
if
len
(
attrs
)
==
0
{
...
...
backend/internal/repository/user_repo_sort_integration_test.go
View file @
bf3ef2d1
...
...
@@ -10,6 +10,24 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
func
(
s
*
UserRepoSuite
)
mustInsertUsageLog
(
userID
int64
,
createdAt
time
.
Time
)
{
s
.
T
()
.
Helper
()
account
:=
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"usage-log-account"
})
apiKey
:=
mustCreateApiKey
(
s
.
T
(),
s
.
client
,
&
service
.
APIKey
{
UserID
:
userID
})
_
,
err
:=
integrationDB
.
ExecContext
(
s
.
ctx
,
`INSERT INTO usage_logs (user_id, api_key_id, account_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at)
VALUES ($1, $2, $3, 'gpt-test', 1, 1, 0.01, 0.01, $4)`
,
userID
,
apiKey
.
ID
,
account
.
ID
,
createdAt
.
UTC
(),
)
s
.
Require
()
.
NoError
(
err
)
}
func
(
s
*
UserRepoSuite
)
TestListWithFilters_SortByEmailAsc
()
{
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"z-last@example.com"
,
Username
:
"z-user"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"a-first@example.com"
,
Username
:
"a-user"
})
...
...
@@ -119,4 +137,49 @@ func (s *UserRepoSuite) TestListWithFilters_SortByLastActiveAtAsc() {
s
.
Require
()
.
Equal
(
"nil-active@example.com"
,
users
[
2
]
.
Email
)
}
func
(
s
*
UserRepoSuite
)
TestGetLatestUsedAtByUserIDs_UsesUsageLogs
()
{
older
:=
time
.
Now
()
.
Add
(
-
4
*
time
.
Hour
)
.
UTC
()
.
Truncate
(
time
.
Second
)
newer
:=
time
.
Now
()
.
Add
(
-
90
*
time
.
Minute
)
.
UTC
()
.
Truncate
(
time
.
Second
)
userWithUsage
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"usage-source@example.com"
})
userWithoutUsage
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"usage-missing@example.com"
})
s
.
mustInsertUsageLog
(
userWithUsage
.
ID
,
older
)
s
.
mustInsertUsageLog
(
userWithUsage
.
ID
,
newer
)
got
,
err
:=
s
.
repo
.
GetLatestUsedAtByUserIDs
(
s
.
ctx
,
[]
int64
{
userWithUsage
.
ID
,
userWithoutUsage
.
ID
})
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Contains
(
got
,
userWithUsage
.
ID
)
s
.
Require
()
.
NotContains
(
got
,
userWithoutUsage
.
ID
)
s
.
Require
()
.
NotNil
(
got
[
userWithUsage
.
ID
])
s
.
Require
()
.
True
(
got
[
userWithUsage
.
ID
]
.
Equal
(
newer
))
}
func
(
s
*
UserRepoSuite
)
TestListWithFilters_SortByLastUsedAtDesc_UsesUsageLogsNotLastActiveAt
()
{
lastUsedOlder
:=
time
.
Now
()
.
Add
(
-
6
*
time
.
Hour
)
.
UTC
()
.
Truncate
(
time
.
Second
)
lastUsedNewer
:=
time
.
Now
()
.
Add
(
-
2
*
time
.
Hour
)
.
UTC
()
.
Truncate
(
time
.
Second
)
lastActiveVeryRecent
:=
time
.
Now
()
.
Add
(
-
10
*
time
.
Minute
)
.
UTC
()
.
Truncate
(
time
.
Second
)
nilUsage
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"nil-last-used@example.com"
})
wrongSource
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"active-not-usage@example.com"
,
LastActiveAt
:
&
lastActiveVeryRecent
,
})
rightSource
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"usage-wins@example.com"
})
s
.
mustInsertUsageLog
(
wrongSource
.
ID
,
lastUsedOlder
)
s
.
mustInsertUsageLog
(
rightSource
.
ID
,
lastUsedNewer
)
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
,
SortBy
:
"last_used_at"
,
SortOrder
:
"desc"
,
},
service
.
UserListFilters
{})
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
users
,
3
)
s
.
Require
()
.
Equal
(
rightSource
.
ID
,
users
[
0
]
.
ID
)
s
.
Require
()
.
Equal
(
wrongSource
.
ID
,
users
[
1
]
.
ID
)
s
.
Require
()
.
Equal
(
nilUsage
.
ID
,
users
[
2
]
.
ID
)
}
func
TestUserRepoSortSuiteSmoke
(
_
*
testing
.
T
)
{}
backend/internal/service/admin_service.go
View file @
bf3ef2d1
...
...
@@ -557,6 +557,20 @@ func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, fi
if
err
!=
nil
{
return
nil
,
0
,
err
}
if
len
(
users
)
>
0
{
userIDs
:=
make
([]
int64
,
0
,
len
(
users
))
for
i
:=
range
users
{
userIDs
=
append
(
userIDs
,
users
[
i
]
.
ID
)
}
lastUsedByUserID
,
latestErr
:=
s
.
userRepo
.
GetLatestUsedAtByUserIDs
(
ctx
,
userIDs
)
if
latestErr
!=
nil
{
logger
.
LegacyPrintf
(
"service.admin"
,
"failed to load user last_used_at in batch: err=%v"
,
latestErr
)
}
else
{
for
i
:=
range
users
{
users
[
i
]
.
LastUsedAt
=
lastUsedByUserID
[
users
[
i
]
.
ID
]
}
}
}
// 批量加载用户专属分组倍率
if
s
.
userGroupRateRepo
!=
nil
&&
len
(
users
)
>
0
{
if
batchRepo
,
ok
:=
s
.
userGroupRateRepo
.
(
userGroupRateBatchReader
);
ok
{
...
...
@@ -601,6 +615,12 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error)
if
err
!=
nil
{
return
nil
,
err
}
lastUsedAt
,
latestErr
:=
s
.
userRepo
.
GetLatestUsedAtByUserID
(
ctx
,
id
)
if
latestErr
!=
nil
{
logger
.
LegacyPrintf
(
"service.admin"
,
"failed to load user last_used_at: user_id=%d err=%v"
,
id
,
latestErr
)
}
else
{
user
.
LastUsedAt
=
lastUsedAt
}
// 加载用户专属分组倍率
if
s
.
userGroupRateRepo
!=
nil
{
rates
,
err
:=
s
.
userGroupRateRepo
.
GetByUserID
(
ctx
,
id
)
...
...
backend/internal/service/admin_service_list_users_test.go
View file @
bf3ef2d1
...
...
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
...
...
@@ -16,6 +17,8 @@ type userRepoStubForListUsers struct {
users
[]
User
err
error
listWithFiltersParams
pagination
.
PaginationParams
lastUsedByUserID
map
[
int64
]
*
time
.
Time
lastUsedErr
error
}
func
(
s
*
userRepoStubForListUsers
)
ListWithFilters
(
_
context
.
Context
,
params
pagination
.
PaginationParams
,
_
UserListFilters
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
{
...
...
@@ -32,6 +35,26 @@ func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pag
},
nil
}
func
(
s
*
userRepoStubForListUsers
)
GetLatestUsedAtByUserIDs
(
_
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
if
s
.
lastUsedErr
!=
nil
{
return
nil
,
s
.
lastUsedErr
}
result
:=
make
(
map
[
int64
]
*
time
.
Time
,
len
(
userIDs
))
for
_
,
userID
:=
range
userIDs
{
if
ts
,
ok
:=
s
.
lastUsedByUserID
[
userID
];
ok
{
result
[
userID
]
=
ts
}
}
return
result
,
nil
}
func
(
s
*
userRepoStubForListUsers
)
GetLatestUsedAtByUserID
(
_
context
.
Context
,
userID
int64
)
(
*
time
.
Time
,
error
)
{
if
s
.
lastUsedErr
!=
nil
{
return
nil
,
s
.
lastUsedErr
}
return
s
.
lastUsedByUserID
[
userID
],
nil
}
type
userGroupRateRepoStubForListUsers
struct
{
batchCalls
int
singleCall
[]
int64
...
...
@@ -130,3 +153,21 @@ func TestAdminService_ListUsers_PassesSortParams(t *testing.T) {
SortOrder
:
"ASC"
,
},
userRepo
.
listWithFiltersParams
)
}
func
TestAdminService_ListUsers_PopulatesLastUsedAt
(
t
*
testing
.
T
)
{
lastUsed
:=
time
.
Now
()
.
UTC
()
.
Add
(
-
30
*
time
.
Minute
)
.
Truncate
(
time
.
Second
)
userRepo
:=
&
userRepoStubForListUsers
{
users
:
[]
User
{{
ID
:
101
,
Email
:
"u@example.com"
}},
lastUsedByUserID
:
map
[
int64
]
*
time
.
Time
{
101
:
&
lastUsed
,
},
}
svc
:=
&
adminServiceImpl
{
userRepo
:
userRepo
}
users
,
total
,
err
:=
svc
.
ListUsers
(
context
.
Background
(),
1
,
20
,
UserListFilters
{},
""
,
""
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
int64
(
1
),
total
)
require
.
Len
(
t
,
users
,
1
)
require
.
NotNil
(
t
,
users
[
0
]
.
LastUsedAt
)
require
.
WithinDuration
(
t
,
lastUsed
,
*
users
[
0
]
.
LastUsedAt
,
time
.
Second
)
}
backend/internal/service/user.go
View file @
bf3ef2d1
...
...
@@ -26,6 +26,7 @@ type User struct {
SignupSource
string
LastLoginAt
*
time
.
Time
LastActiveAt
*
time
.
Time
LastUsedAt
*
time
.
Time
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
...
...
frontend/src/types/index.ts
View file @
bf3ef2d1
...
...
@@ -93,6 +93,7 @@ export interface User {
export
interface
AdminUser
extends
User
{
// 管理员备注(普通用户接口不返回)
notes
:
string
last_used_at
?:
string
|
null
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
group_rates
?:
Record
<
number
,
number
>
// 当前并发数(仅管理员列表接口返回)
...
...
frontend/src/views/admin/UsersView.vue
View file @
bf3ef2d1
...
...
@@ -461,6 +461,12 @@
</span>
</
template
>
<
template
#cell-last_used_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
'
-
'
}}
</span>
</
template
>
<
template
#cell-last_active_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
'
-
'
}}
...
...
@@ -713,6 +719,7 @@ const allColumns = computed<Column[]>(() => [
{
key
:
'
concurrency
'
,
label
:
t
(
'
admin.users.columns.concurrency
'
),
sortable
:
true
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.users.columns.status
'
),
sortable
:
true
},
{
key
:
'
last_login_at
'
,
label
:
t
(
'
admin.users.columns.lastLogin
'
),
sortable
:
true
},
{
key
:
'
last_used_at
'
,
label
:
t
(
'
admin.users.columns.lastUsed
'
),
sortable
:
true
},
{
key
:
'
last_active_at
'
,
label
:
t
(
'
admin.users.columns.lastActive
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
admin.users.columns.created
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.users.columns.actions
'
),
sortable
:
false
}
...
...
@@ -801,7 +808,7 @@ const searchQuery = ref('')
const
USER_SORT_STORAGE_KEY
=
'
admin-users-table-sort
'
const
loadInitialSortState
=
():
{
sort_by
:
string
;
sort_order
:
'
asc
'
|
'
desc
'
}
=>
{
const
fallback
=
{
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
const
sortable
=
new
Set
([
'
email
'
,
'
id
'
,
'
username
'
,
'
role
'
,
'
balance
'
,
'
concurrency
'
,
'
status
'
,
'
last_login_at
'
,
'
last_active_at
'
,
'
created_at
'
])
const
sortable
=
new
Set
([
'
email
'
,
'
id
'
,
'
username
'
,
'
role
'
,
'
balance
'
,
'
concurrency
'
,
'
status
'
,
'
last_login_at
'
,
'
last_used_at
'
,
'
last_active_at
'
,
'
created_at
'
])
try
{
const
raw
=
localStorage
.
getItem
(
USER_SORT_STORAGE_KEY
)
if
(
!
raw
)
return
fallback
...
...
frontend/src/views/admin/__tests__/UsersView.spec.ts
0 → 100644
View file @
bf3ef2d1
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
type
{
AdminUser
}
from
'
@/types
'
import
UsersView
from
'
../UsersView.vue
'
const
{
listUsers
,
getAllGroups
,
getBatchUsersUsage
,
listEnabledDefinitions
,
getBatchUserAttributes
}
=
vi
.
hoisted
(()
=>
({
listUsers
:
vi
.
fn
(),
getAllGroups
:
vi
.
fn
(),
getBatchUsersUsage
:
vi
.
fn
(),
listEnabledDefinitions
:
vi
.
fn
(),
getBatchUserAttributes
:
vi
.
fn
()
}))
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
users
:
{
list
:
listUsers
,
toggleStatus
:
vi
.
fn
(),
delete
:
vi
.
fn
()
},
groups
:
{
getAll
:
getAllGroups
},
dashboard
:
{
getBatchUsersUsage
},
userAttributes
:
{
listEnabledDefinitions
,
getBatchUserAttributes
}
}
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
:
vi
.
fn
(),
showSuccess
:
vi
.
fn
()
})
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}
})
const
createAdminUser
=
():
AdminUser
=>
({
id
:
42
,
username
:
'
scoped-user
'
,
email
:
'
scoped@example.com
'
,
role
:
'
user
'
,
balance
:
0
,
concurrency
:
1
,
status
:
'
active
'
,
allowed_groups
:
[],
balance_notify_enabled
:
false
,
balance_notify_threshold
:
null
,
balance_notify_extra_emails
:
[],
created_at
:
'
2026-04-17T00:00:00Z
'
,
updated_at
:
'
2026-04-17T00:00:00Z
'
,
notes
:
''
,
last_login_at
:
'
2026-04-16T01:00:00Z
'
,
last_active_at
:
'
2026-04-16T02:00:00Z
'
,
last_used_at
:
'
2026-04-17T02:00:00Z
'
,
current_concurrency
:
0
})
const
DataTableStub
=
{
props
:
[
'
columns
'
,
'
data
'
],
emits
:
[
'
sort
'
],
template
:
`
<div>
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
<div v-for="row in data" :key="row.id">
<slot name="cell-last_used_at" :value="row.last_used_at" :row="row" />
</div>
</div>
`
}
describe
(
'
admin UsersView
'
,
()
=>
{
beforeEach
(()
=>
{
localStorage
.
clear
()
listUsers
.
mockReset
()
getAllGroups
.
mockReset
()
getBatchUsersUsage
.
mockReset
()
listEnabledDefinitions
.
mockReset
()
getBatchUserAttributes
.
mockReset
()
listUsers
.
mockResolvedValue
({
items
:
[
createAdminUser
()],
total
:
1
,
page
:
1
,
page_size
:
20
,
pages
:
1
})
getAllGroups
.
mockResolvedValue
([])
getBatchUsersUsage
.
mockResolvedValue
({
stats
:
{}
})
listEnabledDefinitions
.
mockResolvedValue
([])
getBatchUserAttributes
.
mockResolvedValue
({
values
:
{}
})
})
it
(
'
shows last_used_at column and requests last_used_at sort
'
,
async
()
=>
{
const
wrapper
=
mount
(
UsersView
,
{
global
:
{
stubs
:
{
AppLayout
:
{
template
:
'
<div><slot /></div>
'
},
TablePageLayout
:
{
template
:
'
<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>
'
},
DataTable
:
DataTableStub
,
Pagination
:
true
,
ConfirmDialog
:
true
,
EmptyState
:
true
,
GroupBadge
:
true
,
Select
:
true
,
UserAttributesConfigModal
:
true
,
UserConcurrencyCell
:
true
,
UserCreateModal
:
true
,
UserEditModal
:
true
,
UserApiKeysModal
:
true
,
UserAllowedGroupsModal
:
true
,
UserBalanceModal
:
true
,
UserBalanceHistoryModal
:
true
,
GroupReplaceModal
:
true
,
Icon
:
true
,
Teleport
:
true
}
}
})
await
flushPromises
()
expect
(
wrapper
.
get
(
'
[data-test="columns"]
'
).
text
()).
toContain
(
'
last_used_at
'
)
await
wrapper
.
get
(
'
[data-test="sort-last-used"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
listUsers
).
toHaveBeenLastCalledWith
(
1
,
20
,
expect
.
objectContaining
({
sort_by
:
'
last_used_at
'
,
sort_order
:
'
desc
'
}),
expect
.
any
(
Object
)
)
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment