fix profile activity and migration remediation
This commit is contained in:
@@ -45,6 +45,14 @@ type stubAdminService struct {
|
||||
sortOrder string
|
||||
calls int
|
||||
}
|
||||
lastListUsers struct {
|
||||
page int
|
||||
pageSize int
|
||||
filters service.UserListFilters
|
||||
sortBy string
|
||||
sortOrder string
|
||||
calls int
|
||||
}
|
||||
lastListProxies struct {
|
||||
protocol string
|
||||
status string
|
||||
@@ -139,6 +147,12 @@ func newStubAdminService() *stubAdminService {
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
|
||||
s.lastListUsers.page = page
|
||||
s.lastListUsers.pageSize = pageSize
|
||||
s.lastListUsers.filters = filters
|
||||
s.lastListUsers.sortBy = sortBy
|
||||
s.lastListUsers.sortOrder = sortOrder
|
||||
s.lastListUsers.calls++
|
||||
return s.users, int64(len(s.users)), nil
|
||||
}
|
||||
|
||||
|
||||
120
backend/internal/handler/admin/user_handler_activity_test.go
Normal file
120
backend/internal/handler/admin/user_handler_activity_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
lastLoginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
|
||||
lastActiveAt := lastLoginAt.Add(30 * time.Minute)
|
||||
lastUsedAt := lastLoginAt.Add(90 * time.Minute)
|
||||
|
||||
adminSvc := newStubAdminService()
|
||||
adminSvc.users = []service.User{
|
||||
{
|
||||
ID: 7,
|
||||
Email: "activity@example.com",
|
||||
Username: "activity-user",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
LastLoginAt: &lastLoginAt,
|
||||
LastActiveAt: &lastActiveAt,
|
||||
LastUsedAt: &lastUsedAt,
|
||||
CreatedAt: lastLoginAt.Add(-24 * time.Hour),
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/admin/users?sort_by=last_used_at&sort_order=asc&search=activity",
|
||||
nil,
|
||||
)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, "last_used_at", adminSvc.lastListUsers.sortBy)
|
||||
require.Equal(t, "asc", adminSvc.lastListUsers.sortOrder)
|
||||
require.Equal(t, "activity", adminSvc.lastListUsers.filters.Search)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
LastActiveAt *time.Time `json:"last_active_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||
require.Equal(t, 0, resp.Code)
|
||||
require.Len(t, resp.Data.Items, 1)
|
||||
require.WithinDuration(t, lastLoginAt, *resp.Data.Items[0].LastLoginAt, time.Second)
|
||||
require.WithinDuration(t, lastActiveAt, *resp.Data.Items[0].LastActiveAt, time.Second)
|
||||
require.WithinDuration(t, lastUsedAt, *resp.Data.Items[0].LastUsedAt, time.Second)
|
||||
}
|
||||
|
||||
func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
lastLoginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
|
||||
lastActiveAt := lastLoginAt.Add(30 * time.Minute)
|
||||
lastUsedAt := lastLoginAt.Add(90 * time.Minute)
|
||||
|
||||
adminSvc := newStubAdminService()
|
||||
adminSvc.users = []service.User{
|
||||
{
|
||||
ID: 8,
|
||||
Email: "detail@example.com",
|
||||
Username: "detail-user",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
LastLoginAt: &lastLoginAt,
|
||||
LastActiveAt: &lastActiveAt,
|
||||
LastUsedAt: &lastUsedAt,
|
||||
CreatedAt: lastLoginAt.Add(-24 * time.Hour),
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Params = gin.Params{{Key: "id", Value: "8"}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/8", nil)
|
||||
|
||||
handler.GetByID(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
LastActiveAt *time.Time `json:"last_active_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||
require.Equal(t, 0, resp.Code)
|
||||
require.WithinDuration(t, lastLoginAt, *resp.Data.LastLoginAt, time.Second)
|
||||
require.WithinDuration(t, lastActiveAt, *resp.Data.LastActiveAt, time.Second)
|
||||
require.WithinDuration(t, lastUsedAt, *resp.Data.LastUsedAt, time.Second)
|
||||
}
|
||||
@@ -71,8 +71,15 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, linuxdoBinding["bound"])
|
||||
|
||||
_, hasAvatarSource := resp.Data["avatar_source"]
|
||||
require.False(t, hasAvatarSource)
|
||||
_, hasProfileSources := resp.Data["profile_sources"]
|
||||
require.False(t, hasProfileSources)
|
||||
avatarSource, ok := resp.Data["avatar_source"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "linuxdo", avatarSource["provider"])
|
||||
require.Equal(t, "linuxdo", avatarSource["source"])
|
||||
|
||||
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
usernameSource, ok := profileSources["username"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "linuxdo", usernameSource["provider"])
|
||||
require.Equal(t, "linuxdo", usernameSource["source"])
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -352,16 +353,22 @@ func userProfileResponseFromService(user *service.User, identities service.UserI
|
||||
return userProfileResponse{}
|
||||
}
|
||||
bindings := userProfileBindingMap(identities)
|
||||
profileSources, avatarSource, usernameSource := inferUserProfileSources(user, identities)
|
||||
return userProfileResponse{
|
||||
User: *base,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Identities: identities,
|
||||
AuthBindings: bindings,
|
||||
IdentityBindings: bindings,
|
||||
EmailBound: identities.Email.Bound,
|
||||
LinuxDoBound: identities.LinuxDo.Bound,
|
||||
OIDCBound: identities.OIDC.Bound,
|
||||
WeChatBound: identities.WeChat.Bound,
|
||||
User: *base,
|
||||
AvatarURL: user.AvatarURL,
|
||||
AvatarSource: avatarSource,
|
||||
UsernameSource: usernameSource,
|
||||
DisplayNameSource: usernameSource,
|
||||
NicknameSource: usernameSource,
|
||||
ProfileSources: profileSources,
|
||||
Identities: identities,
|
||||
AuthBindings: bindings,
|
||||
IdentityBindings: bindings,
|
||||
EmailBound: identities.Email.Bound,
|
||||
LinuxDoBound: identities.LinuxDo.Bound,
|
||||
OIDCBound: identities.OIDC.Bound,
|
||||
WeChatBound: identities.WeChat.Bound,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,3 +380,66 @@ func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string
|
||||
"wechat": identities.WeChat,
|
||||
}
|
||||
}
|
||||
|
||||
func inferUserProfileSources(user *service.User, identities service.UserIdentitySummarySet) (
|
||||
map[string]*userProfileSourceContext,
|
||||
*userProfileSourceContext,
|
||||
*userProfileSourceContext,
|
||||
) {
|
||||
if user == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
thirdParty := thirdPartyIdentityProviders(identities)
|
||||
var avatarSource *userProfileSourceContext
|
||||
if strings.TrimSpace(user.AvatarURL) != "" && len(thirdParty) == 1 {
|
||||
avatarSource = buildUserProfileSourceContext(thirdParty[0].Provider)
|
||||
}
|
||||
|
||||
usernameValue := strings.TrimSpace(user.Username)
|
||||
var usernameSource *userProfileSourceContext
|
||||
for _, summary := range thirdParty {
|
||||
if usernameValue != "" && usernameValue == strings.TrimSpace(summary.DisplayName) {
|
||||
usernameSource = buildUserProfileSourceContext(summary.Provider)
|
||||
break
|
||||
}
|
||||
}
|
||||
if usernameSource == nil && usernameValue != "" && len(thirdParty) == 1 {
|
||||
usernameSource = buildUserProfileSourceContext(thirdParty[0].Provider)
|
||||
}
|
||||
|
||||
profileSources := map[string]*userProfileSourceContext{}
|
||||
if avatarSource != nil {
|
||||
profileSources["avatar"] = avatarSource
|
||||
}
|
||||
if usernameSource != nil {
|
||||
profileSources["username"] = usernameSource
|
||||
profileSources["display_name"] = usernameSource
|
||||
profileSources["nickname"] = usernameSource
|
||||
}
|
||||
if len(profileSources) == 0 {
|
||||
return nil, avatarSource, usernameSource
|
||||
}
|
||||
return profileSources, avatarSource, usernameSource
|
||||
}
|
||||
|
||||
func thirdPartyIdentityProviders(identities service.UserIdentitySummarySet) []service.UserIdentitySummary {
|
||||
out := make([]service.UserIdentitySummary, 0, 3)
|
||||
for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat} {
|
||||
if summary.Bound {
|
||||
out = append(out, summary)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildUserProfileSourceContext(provider string) *userProfileSourceContext {
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
return nil
|
||||
}
|
||||
return &userProfileSourceContext{
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
||||
require.Equal(t, false, resp.Data["wechat_bound"])
|
||||
require.Equal(t, "https://cdn.example.com/linuxdo.png", resp.Data["avatar_url"])
|
||||
|
||||
avatarSource, ok := resp.Data["avatar_source"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "linuxdo", avatarSource["provider"])
|
||||
require.Equal(t, "linuxdo", avatarSource["source"])
|
||||
|
||||
authBindings, ok := resp.Data["auth_bindings"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any)
|
||||
@@ -298,10 +303,12 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, emailBinding["bound"])
|
||||
|
||||
_, hasAvatarSource := resp.Data["avatar_source"]
|
||||
require.False(t, hasAvatarSource)
|
||||
_, hasProfileSources := resp.Data["profile_sources"]
|
||||
require.False(t, hasProfileSources)
|
||||
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
usernameSource, ok := profileSources["username"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "linuxdo", usernameSource["provider"])
|
||||
require.Equal(t, "linuxdo", usernameSource["source"])
|
||||
}
|
||||
|
||||
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
||||
|
||||
@@ -161,6 +161,10 @@ type userAuthIdentityReader interface {
|
||||
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
||||
}
|
||||
|
||||
type userProfileIdentityTxRunner interface {
|
||||
WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
@@ -249,9 +253,38 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs
|
||||
|
||||
// UpdateProfile 更新用户资料
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
|
||||
if txRunner, ok := s.userRepo.(userProfileIdentityTxRunner); ok {
|
||||
var (
|
||||
updated *User
|
||||
oldConcurrency int
|
||||
)
|
||||
if err := txRunner.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
|
||||
var err error
|
||||
updated, oldConcurrency, err = s.updateProfile(txCtx, userID, req)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.authCacheInvalidator != nil && updated != nil && updated.Concurrency != oldConcurrency {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
updated, oldConcurrency, err := s.updateProfile(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.authCacheInvalidator != nil && updated.Concurrency != oldConcurrency {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *UserService) updateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, int, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
return nil, 0, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
oldConcurrency := user.Concurrency
|
||||
|
||||
@@ -260,10 +293,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
// 检查新邮箱是否已被使用
|
||||
exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check email exists: %w", err)
|
||||
return nil, oldConcurrency, fmt.Errorf("check email exists: %w", err)
|
||||
}
|
||||
if exists && *req.Email != user.Email {
|
||||
return nil, ErrEmailExists
|
||||
return nil, oldConcurrency, ErrEmailExists
|
||||
}
|
||||
user.Email = *req.Email
|
||||
}
|
||||
@@ -275,7 +308,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
if req.AvatarURL != nil {
|
||||
avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, oldConcurrency, err
|
||||
}
|
||||
applyUserAvatar(user, avatar)
|
||||
}
|
||||
@@ -296,13 +329,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
if s.authCacheInvalidator != nil && user.Concurrency != oldConcurrency {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||
return nil, oldConcurrency, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return user, oldConcurrency, nil
|
||||
}
|
||||
|
||||
func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {
|
||||
|
||||
@@ -31,13 +31,26 @@ type mockUserRepo struct {
|
||||
deleteAvatarFn func(ctx context.Context, userID int64) error
|
||||
deleteAvatarIDs []int64
|
||||
getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
|
||||
txCalls int
|
||||
}
|
||||
|
||||
type mockUserRepoTxKey struct{}
|
||||
|
||||
type mockUserRepoTxState struct {
|
||||
getByIDUser *User
|
||||
upsertAvatarArgs []UpsertUserAvatarInput
|
||||
deleteAvatarIDs []int64
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
||||
func (m *mockUserRepo) GetByID(context.Context, int64) (*User, error) {
|
||||
func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
|
||||
if m.getByIDErr != nil {
|
||||
return nil, m.getByIDErr
|
||||
}
|
||||
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil && txState.getByIDUser != nil {
|
||||
cloned := *txState.getByIDUser
|
||||
return &cloned, nil
|
||||
}
|
||||
if m.getByIDUser != nil {
|
||||
cloned := *m.getByIDUser
|
||||
return &cloned, nil
|
||||
@@ -61,6 +74,27 @@ func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAv
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
|
||||
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
|
||||
txState.upsertAvatarArgs = append(txState.upsertAvatarArgs, input)
|
||||
if txState.getByIDUser != nil {
|
||||
txState.getByIDUser.AvatarURL = input.URL
|
||||
txState.getByIDUser.AvatarSource = input.StorageProvider
|
||||
txState.getByIDUser.AvatarMIME = input.ContentType
|
||||
txState.getByIDUser.AvatarByteSize = input.ByteSize
|
||||
txState.getByIDUser.AvatarSHA256 = input.SHA256
|
||||
}
|
||||
if m.upsertAvatarFn != nil {
|
||||
return m.upsertAvatarFn(ctx, userID, input)
|
||||
}
|
||||
return &UserAvatar{
|
||||
StorageProvider: input.StorageProvider,
|
||||
StorageKey: input.StorageKey,
|
||||
URL: input.URL,
|
||||
ContentType: input.ContentType,
|
||||
ByteSize: input.ByteSize,
|
||||
SHA256: input.SHA256,
|
||||
}, nil
|
||||
}
|
||||
m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
|
||||
if m.upsertAvatarFn != nil {
|
||||
return m.upsertAvatarFn(ctx, userID, input)
|
||||
@@ -75,6 +109,20 @@ func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input
|
||||
}, nil
|
||||
}
|
||||
func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
|
||||
txState.deleteAvatarIDs = append(txState.deleteAvatarIDs, userID)
|
||||
if txState.getByIDUser != nil {
|
||||
txState.getByIDUser.AvatarURL = ""
|
||||
txState.getByIDUser.AvatarSource = ""
|
||||
txState.getByIDUser.AvatarMIME = ""
|
||||
txState.getByIDUser.AvatarByteSize = 0
|
||||
txState.getByIDUser.AvatarSHA256 = ""
|
||||
}
|
||||
if m.deleteAvatarFn != nil {
|
||||
return m.deleteAvatarFn(ctx, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
|
||||
if m.deleteAvatarFn != nil {
|
||||
return m.deleteAvatarFn(ctx, userID)
|
||||
@@ -116,6 +164,26 @@ func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
|
||||
m.txCalls++
|
||||
txState := &mockUserRepoTxState{
|
||||
upsertAvatarArgs: append([]UpsertUserAvatarInput(nil), m.upsertAvatarArgs...),
|
||||
deleteAvatarIDs: append([]int64(nil), m.deleteAvatarIDs...),
|
||||
}
|
||||
if m.getByIDUser != nil {
|
||||
userCopy := *m.getByIDUser
|
||||
txState.getByIDUser = &userCopy
|
||||
}
|
||||
err := fn(context.WithValue(ctx, mockUserRepoTxKey{}, txState))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.getByIDUser = txState.getByIDUser
|
||||
m.upsertAvatarArgs = txState.upsertAvatarArgs
|
||||
m.deleteAvatarIDs = txState.deleteAvatarIDs
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- mock: APIKeyAuthCacheInvalidator ---
|
||||
|
||||
type mockAuthCacheInvalidator struct {
|
||||
@@ -360,6 +428,33 @@ func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
|
||||
require.Empty(t, updated.AvatarSource)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_RollsBackAvatarMutationWhenUserUpdateFails(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 11,
|
||||
Email: "rollback@example.com",
|
||||
AvatarURL: "https://cdn.example.com/original.png",
|
||||
AvatarSource: "remote_url",
|
||||
},
|
||||
updateFn: func(context.Context, *User) error {
|
||||
return errors.New("write user failed")
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
remoteURL := "https://cdn.example.com/new.png"
|
||||
_, err := svc.UpdateProfile(context.Background(), 11, UpdateProfileRequest{
|
||||
AvatarURL: &remoteURL,
|
||||
})
|
||||
|
||||
require.EqualError(t, err, "update user: write user failed")
|
||||
require.Equal(t, 1, repo.txCalls)
|
||||
require.Empty(t, repo.upsertAvatarArgs)
|
||||
require.Empty(t, repo.deleteAvatarIDs)
|
||||
require.Equal(t, "https://cdn.example.com/original.png", repo.getByIDUser.AvatarURL)
|
||||
require.Equal(t, "remote_url", repo.getByIDUser.AvatarSource)
|
||||
}
|
||||
|
||||
func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
|
||||
Reference in New Issue
Block a user