feat: track authenticated user activity

This commit is contained in:
IanShaw027
2026-04-21 14:54:53 +08:00
parent 422f3449a2
commit ed01c59916
10 changed files with 254 additions and 26 deletions

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -153,6 +154,18 @@ func (s *stubUserRepo) Delete(ctx context.Context, id int64) error {
panic("unexpected Delete call") panic("unexpected Delete call")
} }
func (s *stubUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*service.UserAvatar, error) {
return nil, nil
}
func (s *stubUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
panic("unexpected UpsertUserAvatar call")
}
func (s *stubUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
panic("unexpected DeleteUserAvatar call")
}
func (s *stubUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) { func (s *stubUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
panic("unexpected List call") panic("unexpected List call")
} }
@@ -161,6 +174,18 @@ func (s *stubUserRepo) ListWithFilters(ctx context.Context, params pagination.Pa
panic("unexpected ListWithFilters call") panic("unexpected ListWithFilters call")
} }
func (s *stubUserRepo) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
panic("unexpected GetLatestUsedAtByUserIDs call")
}
func (s *stubUserRepo) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
panic("unexpected GetLatestUsedAtByUserID call")
}
func (s *stubUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
panic("unexpected UpdateUserLastActiveAt call")
}
func (s *stubUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error { func (s *stubUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
panic("unexpected UpdateBalance call") panic("unexpected UpdateBalance call")
} }
@@ -189,6 +214,10 @@ func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64
panic("unexpected AddGroupToAllowedGroups call") panic("unexpected AddGroupToAllowedGroups call")
} }
func (s *stubUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]service.UserAuthIdentityRecord, error) {
panic("unexpected ListUserAuthIdentities call")
}
func (s *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { func (s *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
panic("unexpected UpdateTotpSecret call") panic("unexpected UpdateTotpSecret call")
} }

View File

@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"context"
"errors" "errors"
"strings" "strings"
@@ -11,11 +12,19 @@ import (
// NewJWTAuthMiddleware 创建 JWT 认证中间件 // NewJWTAuthMiddleware 创建 JWT 认证中间件
func NewJWTAuthMiddleware(authService *service.AuthService, userService *service.UserService) JWTAuthMiddleware { func NewJWTAuthMiddleware(authService *service.AuthService, userService *service.UserService) JWTAuthMiddleware {
return JWTAuthMiddleware(jwtAuth(authService, userService)) return JWTAuthMiddleware(jwtAuth(authService, userService, userService))
}
type jwtUserReader interface {
GetByID(ctx context.Context, id int64) (*service.User, error)
}
type userActivityToucher interface {
TouchLastActiveForUser(ctx context.Context, user *service.User)
} }
// jwtAuth JWT认证中间件实现 // jwtAuth JWT认证中间件实现
func jwtAuth(authService *service.AuthService, userService *service.UserService) gin.HandlerFunc { func jwtAuth(authService *service.AuthService, userService jwtUserReader, activityToucher userActivityToucher) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// 从Authorization header中提取token // 从Authorization header中提取token
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
@@ -73,6 +82,9 @@ func jwtAuth(authService *service.AuthService, userService *service.UserService)
Concurrency: user.Concurrency, Concurrency: user.Concurrency,
}) })
c.Set(string(ContextKeyUserRole), user.Role) c.Set(string(ContextKeyUserRole), user.Role)
if activityToucher != nil {
activityToucher.TouchLastActiveForUser(c.Request.Context(), user)
}
c.Next() c.Next()
} }

View File

@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@@ -30,6 +31,25 @@ func (r *stubJWTUserRepo) GetByID(_ context.Context, id int64) (*service.User, e
return u, nil return u, nil
} }
func (r *stubJWTUserRepo) GetUserAvatar(_ context.Context, _ int64) (*service.UserAvatar, error) {
return nil, nil
}
func (r *stubJWTUserRepo) UpdateUserLastActiveAt(_ context.Context, _ int64, _ time.Time) error {
return nil
}
type recordingActivityToucher struct {
userIDs []int64
}
func (r *recordingActivityToucher) TouchLastActiveForUser(_ context.Context, user *service.User) {
if user == nil {
return
}
r.userIDs = append(r.userIDs, user.ID)
}
// newJWTTestEnv 创建 JWT 认证中间件测试环境。 // newJWTTestEnv 创建 JWT 认证中间件测试环境。
// 返回 gin.Engine已注册 JWT 中间件)和 AuthService用于生成 Token // 返回 gin.Engine已注册 JWT 中间件)和 AuthService用于生成 Token
func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) { func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
@@ -106,6 +126,45 @@ func TestJWTAuth_ValidToken_LowercaseBearer(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
} }
func TestJWTAuth_ValidToken_TouchesLastActive(t *testing.T) {
user := &service.User{
ID: 1,
Email: "test@example.com",
Role: "user",
Status: service.StatusActive,
Concurrency: 5,
TokenVersion: 1,
}
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
cfg.JWT.AccessTokenExpireMinutes = 60
userRepo := &stubJWTUserRepo{users: map[int64]*service.User{1: user}}
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
toucher := &recordingActivityToucher{}
r := gin.New()
r.Use(jwtAuth(authSvc, userSvc, toucher))
r.GET("/protected", func(c *gin.Context) {
c.Status(http.StatusOK)
})
token, err := authSvc.GenerateToken(user)
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "Bearer "+token)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, []int64{1}, toucher.userIDs)
}
func TestJWTAuth_MissingAuthorizationHeader(t *testing.T) { func TestJWTAuth_MissingAuthorizationHeader(t *testing.T) {
router, _ := newJWTTestEnv(nil) router, _ := newJWTTestEnv(nil)

View File

@@ -88,6 +88,9 @@ func (s *userRepoStubForGroupUpdate) GetLatestUsedAtByUserIDs(context.Context, [
func (s *userRepoStubForGroupUpdate) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) { func (s *userRepoStubForGroupUpdate) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
panic("unexpected") panic("unexpected")
} }
func (s *userRepoStubForGroupUpdate) UpdateUserLastActiveAt(context.Context, int64, time.Time) error {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { func (s *userRepoStubForGroupUpdate) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
panic("unexpected") panic("unexpected")
} }

View File

@@ -107,6 +107,18 @@ func (s *userRepoStub) ListWithFilters(ctx context.Context, params pagination.Pa
panic("unexpected ListWithFilters call") panic("unexpected ListWithFilters call")
} }
func (s *userRepoStub) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
panic("unexpected GetLatestUsedAtByUserIDs call")
}
func (s *userRepoStub) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
panic("unexpected GetLatestUsedAtByUserID call")
}
func (s *userRepoStub) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
panic("unexpected UpdateUserLastActiveAt call")
}
func (s *userRepoStub) UpdateBalance(ctx context.Context, id int64, amount float64) error { func (s *userRepoStub) UpdateBalance(ctx context.Context, id int64, amount float64) error {
panic("unexpected UpdateBalance call") panic("unexpected UpdateBalance call")
} }

View File

@@ -97,6 +97,10 @@ func (s *emailSyncRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*ti
return nil, nil return nil, nil
} }
func (s *emailSyncRepoStub) UpdateUserLastActiveAt(context.Context, int64, time.Time) error {
return nil
}
func (s *emailSyncRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil } func (s *emailSyncRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
func (s *emailSyncRepoStub) DeductBalance(context.Context, int64, float64) error { return nil } func (s *emailSyncRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }

View File

@@ -19,10 +19,13 @@ import (
"log/slog" "log/slog"
"net/url" "net/url"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
xdraw "golang.org/x/image/draw" xdraw "golang.org/x/image/draw"
"golang.org/x/sync/singleflight"
) )
var ( var (
@@ -47,6 +50,8 @@ const (
notifyCodeUserRateWindow = 10 * time.Minute notifyCodeUserRateWindow = 10 * time.Minute
defaultUserIdentityRedirect = "/settings/profile" defaultUserIdentityRedirect = "/settings/profile"
userLastActiveMinTouch = 10 * time.Minute
userLastActiveFailBackoff = 30 * time.Second
) )
var ( var (
@@ -82,6 +87,7 @@ type UserRepository interface {
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error)
GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error)
GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error)
UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error
UpdateBalance(ctx context.Context, id int64, amount float64) error UpdateBalance(ctx context.Context, id int64, amount float64) error
DeductBalance(ctx context.Context, id int64, amount float64) error DeductBalance(ctx context.Context, id int64, amount float64) error
@@ -192,6 +198,8 @@ type UserService struct {
settingRepo SettingRepository settingRepo SettingRepository
authCacheInvalidator APIKeyAuthCacheInvalidator authCacheInvalidator APIKeyAuthCacheInvalidator
billingCache BillingCache billingCache BillingCache
lastActiveTouchL1 sync.Map
lastActiveTouchSF singleflight.Group
} }
// NewUserService 创建用户服务实例 // NewUserService 创建用户服务实例
@@ -788,6 +796,66 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
return user, nil return user, nil
} }
// TouchLastActive 通过防抖更新 users.last_active_at减少鉴权热路径写放大。
// 该操作为尽力而为,不应中断正常请求。
func (s *UserService) TouchLastActive(ctx context.Context, userID int64) {
if s == nil || s.userRepo == nil || userID <= 0 {
return
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
slog.Debug("skip touch user last active after load failure", "user_id", userID, "error", err)
return
}
s.TouchLastActiveForUser(ctx, user)
}
// TouchLastActiveForUser 使用已加载的用户信息更新 last_active_at避免重复读取数据库。
func (s *UserService) TouchLastActiveForUser(ctx context.Context, user *User) {
if s == nil || s.userRepo == nil || user == nil || user.ID <= 0 {
return
}
now := time.Now()
if userLastActiveFresh(user.LastActiveAt, now) {
return
}
if v, ok := s.lastActiveTouchL1.Load(user.ID); ok {
if nextAllowedAt, ok := v.(time.Time); ok && now.Before(nextAllowedAt) {
return
}
}
_, err, _ := s.lastActiveTouchSF.Do(strconv.FormatInt(user.ID, 10), func() (any, error) {
latest := time.Now()
if v, ok := s.lastActiveTouchL1.Load(user.ID); ok {
if nextAllowedAt, ok := v.(time.Time); ok && latest.Before(nextAllowedAt) {
return nil, nil
}
}
if userLastActiveFresh(user.LastActiveAt, latest) {
return nil, nil
}
if err := s.userRepo.UpdateUserLastActiveAt(ctx, user.ID, latest); err != nil {
s.lastActiveTouchL1.Store(user.ID, latest.Add(userLastActiveFailBackoff))
return nil, fmt.Errorf("touch user last active: %w", err)
}
s.lastActiveTouchL1.Store(user.ID, latest.Add(userLastActiveMinTouch))
return nil, nil
})
if err != nil {
slog.Warn("touch user last active failed", "user_id", user.ID, "error", err)
}
}
func userLastActiveFresh(lastActiveAt *time.Time, now time.Time) bool {
if lastActiveAt == nil {
return false
}
return now.Before(lastActiveAt.Add(userLastActiveMinTouch))
}
func (s *UserService) hydrateUserAvatar(ctx context.Context, user *User) error { func (s *UserService) hydrateUserAvatar(ctx context.Context, user *User) error {
if s == nil || s.userRepo == nil || user == nil || user.ID == 0 { if s == nil || s.userRepo == nil || user == nil || user.ID == 0 {
return nil return nil

View File

@@ -27,6 +27,9 @@ type mockUserRepo struct {
updateBalanceFn func(ctx context.Context, id int64, amount float64) error updateBalanceFn func(ctx context.Context, id int64, amount float64) error
getByIDUser *User getByIDUser *User
getByIDErr error getByIDErr error
updateLastActiveErr error
updateLastActiveUserIDs []int64
updateLastActiveAt []time.Time
updateFn func(ctx context.Context, user *User) error updateFn func(ctx context.Context, user *User) error
updateCalls int updateCalls int
upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
@@ -144,6 +147,11 @@ func (m *mockUserRepo) UpdateBalance(ctx context.Context, id int64, amount float
} }
return m.updateBalanceErr return m.updateBalanceErr
} }
func (m *mockUserRepo) UpdateUserLastActiveAt(_ context.Context, userID int64, activeAt time.Time) error {
m.updateLastActiveUserIDs = append(m.updateLastActiveUserIDs, userID)
m.updateLastActiveAt = append(m.updateLastActiveAt, activeAt)
return m.updateLastActiveErr
}
func (m *mockUserRepo) DeductBalance(context.Context, int64, float64) error { return nil } func (m *mockUserRepo) DeductBalance(context.Context, int64, float64) error { return nil }
func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil } func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil }
func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
@@ -288,6 +296,39 @@ func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
}, 2*time.Second, 10*time.Millisecond, "即使失败也应调用 InvalidateUserBalance") }, 2*time.Second, 10*time.Millisecond, "即使失败也应调用 InvalidateUserBalance")
} }
func TestTouchLastActive_UpdatesWhenStale(t *testing.T) {
stale := time.Now().Add(-11 * time.Minute)
repo := &mockUserRepo{
getByIDUser: &User{
ID: 42,
LastActiveAt: &stale,
},
}
svc := NewUserService(repo, nil, nil, nil)
svc.TouchLastActive(context.Background(), 42)
require.Equal(t, []int64{42}, repo.updateLastActiveUserIDs)
require.Len(t, repo.updateLastActiveAt, 1)
require.WithinDuration(t, time.Now(), repo.updateLastActiveAt[0], 2*time.Second)
}
func TestTouchLastActive_SkipsWhenRecent(t *testing.T) {
recent := time.Now().Add(-time.Minute)
repo := &mockUserRepo{
getByIDUser: &User{
ID: 42,
LastActiveAt: &recent,
},
}
svc := NewUserService(repo, nil, nil, nil)
svc.TouchLastActive(context.Background(), 42)
require.Empty(t, repo.updateLastActiveUserIDs)
require.Empty(t, repo.updateLastActiveAt)
}
func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) { func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) {
repo := &mockUserRepo{updateBalanceErr: errors.New("database error")} repo := &mockUserRepo{updateBalanceErr: errors.New("database error")}
cache := &mockBillingCache{} cache := &mockBillingCache{}

View File

@@ -455,12 +455,6 @@
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-last_login_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? formatDateTime(value) : '-' }}
</span>
</template>
<template #cell-last_used_at="{ value }"> <template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400"> <span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? formatDateTime(value) : '-' }} {{ value ? formatDateTime(value) : '-' }}
@@ -718,7 +712,6 @@ const allColumns = computed<Column[]>(() => [
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false }, { key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true }, { key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
{ key: 'status', label: t('admin.users.columns.status'), 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_used_at', label: t('admin.users.columns.lastUsed'), sortable: true },
{ key: 'last_active_at', label: t('admin.users.columns.lastActive'), 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: 'created_at', label: t('admin.users.columns.created'), sortable: true },
@@ -735,7 +728,9 @@ const toggleableColumns = computed(() =>
const hiddenColumns = reactive<Set<string>>(new Set()) const hiddenColumns = reactive<Set<string>>(new Set())
// Default hidden columns (columns hidden by default on first load) // Default hidden columns (columns hidden by default on first load)
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency', 'last_login_at', 'last_active_at'] const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency']
const REMOVED_COLUMNS = new Set(['last_login_at'])
const FORCED_VISIBLE_COLUMNS = new Set(['last_active_at'])
// localStorage key for column settings // localStorage key for column settings
const HIDDEN_COLUMNS_KEY = 'user-hidden-columns' const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
@@ -746,7 +741,9 @@ const loadSavedColumns = () => {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY) const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
if (saved) { if (saved) {
const parsed = JSON.parse(saved) as string[] const parsed = JSON.parse(saved) as string[]
parsed.forEach(key => hiddenColumns.add(key)) parsed
.filter(key => !REMOVED_COLUMNS.has(key) && !FORCED_VISIBLE_COLUMNS.has(key))
.forEach(key => hiddenColumns.add(key))
} else { } else {
// Use default hidden columns on first load // Use default hidden columns on first load
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key)) DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
@@ -808,7 +805,7 @@ const searchQuery = ref('')
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort' const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => { const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
const fallback = { sort_by: 'created_at', sort_order: 'desc' as '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_used_at', 'last_active_at', 'created_at']) const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'last_used_at', 'last_active_at', 'created_at'])
try { try {
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY) const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
if (!raw) return fallback if (!raw) return fallback

View File

@@ -113,7 +113,7 @@ describe('admin UsersView', () => {
getBatchUserAttributes.mockResolvedValue({ values: {} }) getBatchUserAttributes.mockResolvedValue({ values: {} })
}) })
it('shows last_used_at column and requests last_used_at sort', async () => { it('shows active and used activity columns, hides last_login_at, and requests last_used_at sort', async () => {
const wrapper = mount(UsersView, { const wrapper = mount(UsersView, {
global: { global: {
stubs: { stubs: {
@@ -144,7 +144,10 @@ describe('admin UsersView', () => {
await flushPromises() await flushPromises()
expect(wrapper.get('[data-test="columns"]').text()).toContain('last_used_at') const columns = wrapper.get('[data-test="columns"]').text()
expect(columns).toContain('last_used_at')
expect(columns).toContain('last_active_at')
expect(columns).not.toContain('last_login_at')
await wrapper.get('[data-test="sort-last-used"]').trigger('click') await wrapper.get('[data-test="sort-last-used"]').trigger('click')
await flushPromises() await flushPromises()