veza/veza-backend-api/internal/services/user_service_test.go

498 lines
12 KiB
Go

package services
import (
"bytes"
"context"
"errors"
"mime/multipart"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/models"
"veza-backend-api/internal/types"
)
// ========== MOCK REPOSITORY ==========
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetByID(id string) (*models.User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *MockUserRepository) GetByEmail(email string) (*models.User, error) {
args := m.Called(email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *MockUserRepository) GetByUsername(username string) (*models.User, error) {
args := m.Called(username)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *MockUserRepository) Create(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserRepository) Update(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserRepository) Delete(id string) error {
args := m.Called(id)
return args.Error(0)
}
// ========== TEST DATABASE SETUP ==========
func setupUserTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to connect to test database: %v", err)
}
// Auto-migrate les modèles nécessaires
err = db.AutoMigrate(
&models.User{},
&models.UserSettings{},
&models.UserProfile{},
)
if err != nil {
t.Fatalf("Failed to migrate: %v", err)
}
return db
}
// ========== TESTS ==========
func TestUserService_GetProfile_Success(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
user := &models.User{
ID: userID,
Username: "testuser",
FirstName: "Test",
LastName: "User",
Email: "test@example.com",
CreatedAt: time.Now(),
IsPublic: true,
}
mockRepo.On("GetByID", userID.String()).Return(user, nil)
// Execute
profile, err := service.GetProfile(userID, &userID)
// Assert
assert.NoError(t, err)
assert.NotNil(t, profile)
assert.Equal(t, userID, profile.ID)
assert.Equal(t, "testuser", profile.Username)
mockRepo.AssertExpectations(t)
}
func TestUserService_GetProfile_NotFound(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
mockRepo.On("GetByID", userID.String()).Return(nil, errors.New("not found"))
// Execute
profile, err := service.GetProfile(userID, &userID)
// Assert
assert.Error(t, err)
assert.Nil(t, profile)
}
func TestUserService_GetProfile_Private(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
otherID := uuid.New()
bio := "Secret bio"
user := &models.User{
ID: userID,
Username: "privateuser",
IsPublic: false,
Bio: bio,
}
mockRepo.On("GetByID", userID.String()).Return(user, nil)
// Execute as another user
profile, err := service.GetProfile(userID, &otherID)
// Assert
assert.NoError(t, err)
assert.NotNil(t, profile)
assert.Nil(t, profile.Bio, "Bio should be nil for private profile viewed by other")
}
func TestUserService_GetProfileByUsername_Success(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
username := "testuser"
user := &models.User{
ID: userID,
Username: username,
IsPublic: true,
}
mockRepo.On("GetByUsername", username).Return(user, nil)
mockRepo.On("GetByID", userID.String()).Return(user, nil)
// Execute
profile, err := service.GetProfileByUsername(username, &userID)
// Assert
assert.NoError(t, err)
assert.Equal(t, username, profile.Username)
}
func TestUserService_UpdateProfile_Success(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
user := &models.User{
ID: userID,
Username: "oldname",
Bio: "old bio",
}
newName := "newname"
newBio := "new bio"
req := types.UpdateProfileRequest{
Username: &newName,
Bio: &newBio,
}
mockRepo.On("GetByID", userID.String()).Return(user, nil)
mockRepo.On("Update", mock.MatchedBy(func(u *models.User) bool {
return u.Username == "newname" && u.Bio == "new bio"
})).Return(nil)
// Execute
profile, err := service.UpdateProfile(userID, req)
// Assert
assert.NoError(t, err)
assert.Equal(t, "newname", profile.Username)
assert.Equal(t, "new bio", *profile.Bio)
}
func TestUserService_UpdateProfile_WithSocialLinks_Success(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
user := &models.User{
ID: userID,
}
socialLinks := map[string]interface{}{
"twitter": "https://twitter.com/test",
}
req := types.UpdateProfileRequest{
SocialLinks: socialLinks,
}
mockRepo.On("GetByID", userID.String()).Return(user, nil)
mockRepo.On("Update", mock.MatchedBy(func(u *models.User) bool {
return u.SocialLinks != "" && strings.Contains(u.SocialLinks, "twitter")
})).Return(nil)
// Execute
profile, err := service.UpdateProfile(userID, req)
// Assert
assert.NoError(t, err)
assert.NotNil(t, profile.SocialLinks)
assert.Equal(t, "https://twitter.com/test", profile.SocialLinks["twitter"])
mockRepo.AssertExpectations(t)
}
func TestUserService_GetUserSettings_Success(t *testing.T) {
// Setup with DB
db := setupUserTestDB(t)
mockRepo := new(MockUserRepository)
service := NewUserServiceWithDB(mockRepo, db)
userID := uuid.New()
// Create settings in DB
settings := models.UserSettings{
UserID: userID,
EmailNotifications: true,
}
db.Create(&settings)
// Create profile in DB
profile := models.UserProfile{
UserID: userID,
Language: "fr",
Timezone: "Europe/Paris",
}
db.Create(&profile)
// Execute
resp, err := service.GetUserSettings(userID)
// Assert
assert.NoError(t, err)
assert.True(t, resp.Notifications.Email)
assert.Equal(t, "fr", resp.Preferences.Language)
assert.Equal(t, "Europe/Paris", resp.Preferences.Timezone)
}
func TestUserService_UpdateUserSettings_Success(t *testing.T) {
// Setup with DB
db := setupUserTestDB(t)
mockRepo := new(MockUserRepository)
service := NewUserServiceWithDB(mockRepo, db)
userID := uuid.New()
// Initial state
settings := models.UserSettings{UserID: userID, EmailNotifications: false}
db.Create(&settings)
profile := models.UserProfile{UserID: userID, Language: "en"}
db.Create(&profile)
// Request
newLang := "es"
req := types.UpdateSettingsRequest{
Notifications: &types.NotificationSettings{Email: true},
Preferences: &types.PreferenceSettings{Language: newLang},
}
// Execute
err := service.UpdateUserSettings(userID, &req)
// Assert
assert.NoError(t, err)
// Verify DB
var dbSettings models.UserSettings
db.First(&dbSettings, "user_id = ?", userID)
assert.True(t, dbSettings.EmailNotifications)
var dbProfile models.UserProfile
db.First(&dbProfile, "user_id = ?", userID)
assert.Equal(t, "es", dbProfile.Language)
}
func TestUserService_DeleteUser_Success(t *testing.T) {
// Setup
db := setupUserTestDB(t)
mockRepo := new(MockUserRepository)
service := NewUserServiceWithDB(mockRepo, db)
userID := uuid.New()
user := &models.User{ID: userID, IsActive: true}
// Create user in DB for soft delete check (if needed by service implementation detail)
db.Create(user)
mockRepo.On("GetByID", userID.String()).Return(user, nil)
mockRepo.On("Delete", userID.String()).Return(nil)
// Execute
ctx := context.Background()
err := service.DeleteUser(ctx, userID)
// Assert
assert.NoError(t, err)
// Verify IsActive updated in DB (as per implementation)
var dbUser models.User
db.First(&dbUser, "id = ?", userID)
assert.False(t, dbUser.IsActive)
mockRepo.AssertExpectations(t)
}
func TestUserService_UploadAvatar(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
// Use temp dir for uploads
tmpDir, err := os.MkdirTemp("", "avatar_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
service.SetUploadDir(tmpDir)
userID := uuid.New()
content := []byte("fake image content")
fileHeader := createMultipartFileHeader(t, "avatar.png", content, "image/png")
// Execute
url, err := service.UploadAvatar(userID, fileHeader)
// Assert
assert.NoError(t, err)
assert.Contains(t, url, "/uploads/avatars/")
assert.Contains(t, url, ".png")
}
func TestUserService_UpdateAvatarURL(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
user := &models.User{ID: userID}
newAvatar := "/uploads/avatars/new.png"
mockRepo.On("GetByID", userID.String()).Return(user, nil)
mockRepo.On("Update", mock.MatchedBy(func(u *models.User) bool {
return u.Avatar == newAvatar
})).Return(nil)
// Execute
err := service.UpdateAvatarURL(userID, newAvatar)
// Assert
assert.NoError(t, err)
mockRepo.AssertExpectations(t)
}
func TestUserService_ValidateUsername(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
otherID := uuid.New()
currentUsername := "current"
newUsername := "newname"
takenUsername := "taken"
user := &models.User{
ID: userID,
Username: currentUsername,
}
mockRepo.On("GetByID", userID.String()).Return(user, nil)
// Case 1: Username available
mockRepo.On("GetByUsername", newUsername).Return(nil, gorm.ErrRecordNotFound)
// Case 2: Username taken
otherUser := &models.User{ID: otherID, Username: takenUsername}
mockRepo.On("GetByUsername", takenUsername).Return(otherUser, nil)
// Execute Case 1
err := service.ValidateUsername(userID, newUsername)
assert.NoError(t, err)
// Execute Case 2
err = service.ValidateUsername(userID, takenUsername)
assert.Error(t, err)
assert.Equal(t, "username already taken", err.Error())
// Case 3: Rate limit
// (Skipping rate limit test complexity for now as logic is standard time check)
}
func TestUserService_CalculateProfileCompletion(t *testing.T) {
// Setup
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
userID := uuid.New()
avatar := "avatar.png"
bio := "bio"
socialLinks := `{"twitter": "https://twitter.com/test"}`
user := &models.User{
ID: userID,
Username: "complete",
FirstName: "John",
LastName: "Doe",
Bio: bio,
Avatar: avatar,
IsPublic: true,
SocialLinks: socialLinks,
}
mockRepo.On("GetByID", userID.String()).Return(user, nil)
// Execute
completion, err := service.CalculateProfileCompletion(userID)
// Assert
assert.NoError(t, err)
assert.Equal(t, 100, completion.Percentage)
assert.Empty(t, completion.Missing)
}
// Helper
func createMultipartFileHeader(t *testing.T, filename string, content []byte, contentType string) *multipart.FileHeader {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
_, err = part.Write(content)
if err != nil {
t.Fatal(err)
}
err = writer.Close()
if err != nil {
t.Fatal(err)
}
reader := multipart.NewReader(body, writer.Boundary())
form, err := reader.ReadForm(1024 * 1024)
if err != nil {
t.Fatal(err)
}
headers := form.File["file"]
if len(headers) == 0 {
t.Fatal("no file header")
}
headers[0].Header.Set("Content-Type", contentType)
return headers[0]
}