Files
backend/internal/service/profile_service_test.go
lan f2b02682c2
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
feat(yggdrasil): 实现好友系统与 profiles 查询接口
按 MinecraftServices 文档实现好友系四组接口与 profiles 服务:

好友系统(/api/yggdrasil/minecraftservices/*)
- Friends(1.3):列表查询、ADD/REMOVE 操作(发请求/接受/拒绝/撤回/删除)
  - 状态枚举+单向记录模型,接受请求时合并两方向避免好友列表重复
- Player Attributes(1.2):friendsPreferences / 脏词过滤 / 聊天偏好读写
  - Upsert 用 map 显式写字段规避 gorm 对带 default 零值布尔字段的忽略
- Presence(1.1):Redis key+TTL 上报与好友在线状态批量查询
- Blocklist(1.6):屏蔽列表查询(含 120s Redis 缓存),Block/Unblock 内部方法

Profiles 服务
- getManyByName(2.1):POST /api/profiles/minecraft,返回 [{id,name}]
  - toLowerCase 规范化、去重、空名过滤、maxBatch=10 超限拒绝
- getByName(2.2):GET /api/users/profiles/minecraft/:name,返回 {id,name},未找到返回 404

路由与基础设施
- 路由按官方 host 前缀一一转发
  - api.mojang.com/* -> /api/yggdrasil/api/*
  - api.minecraftservices.com/* -> /api/yggdrasil/minecraftservices/*
- 新增 Friend/PlayerAttributes 模型与 AutoMigrate 注册
- 新增 FriendsService 与 Container 装配
- 抽 extractBearerToken helper,统一 MinecraftServices 错误响应

修复
- texture_service: ToggleFavorite 在 db 为 nil 时降级非事务执行
  修复 TestTextureServiceImpl_ToggleFavorite 空指针 panic
- texture_service_test: UploadTexture 用例改用真实 SHA-256 命中 mock
  修复 4 个子用例因文件大小/Hash 不匹配的失败
- profile_repository: GetByNames/FindByName 改 LOWER() 大小写不敏感
  与客户端 toLowerCase 规范化对齐

测试
- 好友模型、ADD/REMOVE/接受、属性、屏蔽、Bearer 解析单测全绿
- profiles 查询规范化、去重、超限、大小写不敏感单测全绿
- go build ./... && go test ./... 全部通过
2026-07-09 20:44:41 +08:00

732 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"carrotskin/internal/model"
"context"
"testing"
"go.uber.org/zap"
)
// TestProfileService_Validation 测试Profile服务验证逻辑
func TestProfileService_Validation(t *testing.T) {
tests := []struct {
name string
userID int64
profileName string
wantValid bool
}{
{
name: "有效的用户ID和角色名",
userID: 1,
profileName: "TestProfile",
wantValid: true,
},
{
name: "用户ID为0时无效",
userID: 0,
profileName: "TestProfile",
wantValid: false,
},
{
name: "角色名为空时无效",
userID: 1,
profileName: "",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.userID > 0 && tt.profileName != ""
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestProfileService_StatusValidation 测试用户状态验证
func TestProfileService_StatusValidation(t *testing.T) {
tests := []struct {
name string
status int16
wantValid bool
}{
{
name: "状态为1正常时有效",
status: 1,
wantValid: true,
},
{
name: "状态为0禁用时无效",
status: 0,
wantValid: false,
},
{
name: "状态为-1删除时无效",
status: -1,
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.status == 1
if isValid != tt.wantValid {
t.Errorf("Status validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑
func TestUpdateProfile_PermissionCheck(t *testing.T) {
tests := []struct {
name string
profileUserID int64
requestUserID int64
wantErr bool
}{
{
name: "用户ID匹配允许操作",
profileUserID: 1,
requestUserID: 1,
wantErr: false,
},
{
name: "用户ID不匹配拒绝操作",
profileUserID: 1,
requestUserID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.profileUserID != tt.requestUserID
if hasError != tt.wantErr {
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestUpdateProfile_NameValidation 测试更新Profile时名称验证逻辑
func TestUpdateProfile_NameValidation(t *testing.T) {
tests := []struct {
name string
currentName string
newName *string
shouldCheck bool
}{
{
name: "名称未改变,不检查",
currentName: "TestProfile",
newName: stringPtr("TestProfile"),
shouldCheck: false,
},
{
name: "名称改变,需要检查",
currentName: "TestProfile",
newName: stringPtr("NewProfile"),
shouldCheck: true,
},
{
name: "名称为nil不检查",
currentName: "TestProfile",
newName: nil,
shouldCheck: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldCheck := tt.newName != nil && *tt.newName != tt.currentName
if shouldCheck != tt.shouldCheck {
t.Errorf("Name validation check failed: got %v, want %v", shouldCheck, tt.shouldCheck)
}
})
}
}
// TestDeleteProfile_PermissionCheck 测试删除Profile的权限检查
func TestDeleteProfile_PermissionCheck(t *testing.T) {
tests := []struct {
name string
profileUserID int64
requestUserID int64
wantErr bool
}{
{
name: "用户ID匹配允许删除",
profileUserID: 1,
requestUserID: 1,
wantErr: false,
},
{
name: "用户ID不匹配拒绝删除",
profileUserID: 1,
requestUserID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.profileUserID != tt.requestUserID
if hasError != tt.wantErr {
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑
func TestCheckProfileLimit_Logic(t *testing.T) {
tests := []struct {
name string
count int
maxProfiles int
wantErr bool
}{
{
name: "未达到上限",
count: 5,
maxProfiles: 10,
wantErr: false,
},
{
name: "达到上限",
count: 10,
maxProfiles: 10,
wantErr: true,
},
{
name: "超过上限",
count: 15,
maxProfiles: 10,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.count >= tt.maxProfiles
if hasError != tt.wantErr {
t.Errorf("Limit check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestValidateProfileByUserID_InputValidation 测试ValidateProfileByUserID输入验证
func TestValidateProfileByUserID_InputValidation(t *testing.T) {
tests := []struct {
name string
userID int64
uuid string
wantErr bool
}{
{
name: "有效输入",
userID: 1,
uuid: "test-uuid",
wantErr: false,
},
{
name: "userID为0",
userID: 0,
uuid: "test-uuid",
wantErr: true,
},
{
name: "uuid为空",
userID: 1,
uuid: "",
wantErr: true,
},
{
name: "两者都无效",
userID: 0,
uuid: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.userID == 0 || tt.uuid == ""
if hasError != tt.wantErr {
t.Errorf("Input validation failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestValidateProfileByUserID_UserIDMatching 测试用户ID匹配逻辑
func TestValidateProfileByUserID_UserIDMatching(t *testing.T) {
tests := []struct {
name string
profileUserID int64
requestUserID int64
wantValid bool
}{
{
name: "用户ID匹配",
profileUserID: 1,
requestUserID: 1,
wantValid: true,
},
{
name: "用户ID不匹配",
profileUserID: 1,
requestUserID: 2,
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.profileUserID == tt.requestUserID
if isValid != tt.wantValid {
t.Errorf("UserID matching failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestGenerateRSAPrivateKey 测试RSA私钥生成
func TestGenerateRSAPrivateKey(t *testing.T) {
tests := []struct {
name string
wantError bool
}{
{
name: "生成RSA私钥",
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
privateKey, err := generateRSAPrivateKeyInternal()
if (err != nil) != tt.wantError {
t.Errorf("generateRSAPrivateKeyInternal() error = %v, wantError %v", err, tt.wantError)
return
}
if !tt.wantError {
if privateKey == "" {
t.Error("generateRSAPrivateKeyInternal() 返回的私钥不应为空")
}
// 验证PEM格式
if len(privateKey) < 100 {
t.Errorf("generateRSAPrivateKeyInternal() 返回的私钥长度异常: %d", len(privateKey))
}
// 验证包含PEM头部
if !contains(privateKey, "BEGIN RSA PRIVATE KEY") {
t.Error("generateRSAPrivateKeyInternal() 返回的私钥应包含PEM头部")
}
}
})
}
}
// TestGenerateRSAPrivateKey_Uniqueness 测试RSA私钥唯一性
func TestGenerateRSAPrivateKey_Uniqueness(t *testing.T) {
keys := make(map[string]bool)
for i := 0; i < 10; i++ {
key, err := generateRSAPrivateKeyInternal()
if err != nil {
t.Fatalf("generateRSAPrivateKeyInternal() 失败: %v", err)
}
if keys[key] {
t.Errorf("第%d次生成的密钥与之前重复", i+1)
}
keys[key] = true
}
}
// 辅助函数
func stringPtr(s string) *string {
return &s
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) && (s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsMiddle(s, substr))))
}
func containsMiddle(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// ============================================================================
// 使用 Mock 的集成测试
// ============================================================================
// TestProfileServiceImpl_Create 测试创建Profile
func TestProfileServiceImpl_Create(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户
testUser := &model.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
Status: 1,
}
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
profileService := NewProfileService(profileRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
userID int64
profileName string
wantErr bool
errMsg string
setupMocks func()
}{
{
name: "正常创建Profile",
userID: 1,
profileName: "TestProfile",
wantErr: false,
},
{
name: "用户不存在",
userID: 999,
profileName: "TestProfile2",
wantErr: true,
errMsg: "用户不存在",
},
{
name: "角色名已存在",
userID: 1,
profileName: "ExistingProfile",
wantErr: true,
errMsg: "角色名已被使用",
setupMocks: func() {
_ = profileRepo.Create(context.Background(), &model.Profile{
UUID: "existing-uuid",
UserID: 2,
Name: "ExistingProfile",
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks != nil {
tt.setupMocks()
}
ctx := context.Background()
profile, err := profileService.Create(ctx, tt.userID, tt.profileName)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("错误信息不匹配: got %v, want %v", err.Error(), tt.errMsg)
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if profile == nil {
t.Error("返回的Profile不应为nil")
}
if profile.Name != tt.profileName {
t.Errorf("Profile名称不匹配: got %v, want %v", profile.Name, tt.profileName)
}
if profile.UUID == "" {
t.Error("Profile UUID不应为空")
}
}
})
}
}
// TestProfileServiceImpl_GetByUUID 测试获取Profile
func TestProfileServiceImpl_GetByUUID(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置Profile
testProfile := &model.Profile{
UUID: "test-uuid-123",
UserID: 1,
Name: "TestProfile",
}
_ = profileRepo.Create(context.Background(), testProfile)
cacheManager := NewMockCacheManager()
profileService := NewProfileService(profileRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
uuid string
wantErr bool
}{
{
name: "获取存在的Profile",
uuid: "test-uuid-123",
wantErr: false,
},
{
name: "获取不存在的Profile",
uuid: "non-existent-uuid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
profile, err := profileService.GetByUUID(ctx, tt.uuid)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if profile == nil {
t.Error("返回的Profile不应为nil")
}
if profile.UUID != tt.uuid {
t.Errorf("Profile UUID不匹配: got %v, want %v", profile.UUID, tt.uuid)
}
}
})
}
}
// TestProfileServiceImpl_Delete 测试删除Profile
func TestProfileServiceImpl_Delete(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置Profile
testProfile := &model.Profile{
UUID: "delete-test-uuid",
UserID: 1,
Name: "DeleteTestProfile",
}
_ = profileRepo.Create(context.Background(), testProfile)
cacheManager := NewMockCacheManager()
profileService := NewProfileService(profileRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
uuid string
userID int64
wantErr bool
}{
{
name: "正常删除",
uuid: "delete-test-uuid",
userID: 1,
wantErr: false,
},
{
name: "用户ID不匹配",
uuid: "delete-test-uuid",
userID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
err := profileService.Delete(ctx, tt.uuid, tt.userID)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
}
}
})
}
}
// TestProfileServiceImpl_GetByUserID 测试按用户获取档案列表
func TestProfileServiceImpl_GetByUserID(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 为用户 1 和 2 预置不同档案
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "p1", UserID: 1, Name: "P1"})
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "p2", UserID: 1, Name: "P2"})
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "p3", UserID: 2, Name: "P3"})
cacheManager := NewMockCacheManager()
svc := NewProfileService(profileRepo, userRepo, cacheManager, logger)
ctx := context.Background()
list, err := svc.GetByUserID(ctx, 1)
if err != nil {
t.Fatalf("GetByUserID 失败: %v", err)
}
if len(list) != 2 {
t.Fatalf("GetByUserID 返回数量错误, got=%d, want=2", len(list))
}
}
// TestProfileServiceImpl_Update 测试 Update
func TestProfileServiceImpl_Update(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
profile := &model.Profile{
UUID: "u1",
UserID: 1,
Name: "OldName",
}
_ = profileRepo.Create(context.Background(), profile)
cacheManager := NewMockCacheManager()
svc := NewProfileService(profileRepo, userRepo, cacheManager, logger)
ctx := context.Background()
// 正常更新名称与皮肤/披风
newName := "NewName"
var skinID int64 = 10
var capeID int64 = 20
updated, err := svc.Update(ctx, "u1", 1, &newName, &skinID, &capeID)
if err != nil {
t.Fatalf("Update 正常情况失败: %v", err)
}
if updated == nil || updated.Name != newName {
t.Fatalf("Update 未更新名称, got=%+v", updated)
}
// 用户无权限
if _, err := svc.Update(ctx, "u1", 2, &newName, nil, nil); err == nil {
t.Fatalf("Update 在无权限时应返回错误")
}
// 名称重复
_ = profileRepo.Create(context.Background(), &model.Profile{
UUID: "u2",
UserID: 2,
Name: "Duplicate",
})
if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil {
t.Fatalf("Update 在名称重复时应返回错误")
}
}
// TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName
func TestProfileServiceImpl_CheckLimit_And_GetByNames(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 为用户 1 预置 2 个档案
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "a", UserID: 1, Name: "A"})
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "b", UserID: 1, Name: "B"})
cacheManager := NewMockCacheManager()
svc := NewProfileService(profileRepo, userRepo, cacheManager, logger)
ctx := context.Background()
// CheckLimit 未达上限
if err := svc.CheckLimit(ctx, 1, 3); err != nil {
t.Fatalf("CheckLimit 未达到上限时不应报错: %v", err)
}
// CheckLimit 达到上限
if err := svc.CheckLimit(ctx, 1, 2); err == nil {
t.Fatalf("CheckLimit 达到上限时应报错")
}
// GetByNames
list, err := svc.GetByNames(ctx, []string{"A", "B"})
if err != nil {
t.Fatalf("GetByNames 失败: %v", err)
}
if len(list) != 2 {
t.Fatalf("GetByNames 返回数量错误, got=%d, want=2", len(list))
}
// GetByProfileName 存在
p, err := svc.GetByProfileName(ctx, "A")
if err != nil || p == nil || p.Name != "A" {
t.Fatalf("GetByProfileName 返回错误, profile=%+v, err=%v", p, err)
}
// ProfileSearchByName小写名查询、去重、过滤空名、超限拒单
got, err := svc.ProfileSearchByName(ctx, []string{"a", "A", "b", "", " "}, 10)
if err != nil {
t.Fatalf("ProfileSearchByName 失败: %v", err)
}
if len(got) != 2 {
t.Fatalf("ProfileSearchByName 应去重返回 2 项, got=%d: %+v", len(got), got)
}
byName := make(map[string]string, len(got))
for _, n := range got {
byName[n.Name] = n.ID
}
if byName["A"] != "a" || byName["B"] != "b" {
t.Fatalf("返回的 id/name 映射错误: %+v", byName)
}
// 超过 maxBatch 应报错
if _, err := svc.ProfileSearchByName(ctx, []string{"a", "b", "c"}, 2); err == nil {
t.Fatalf("ProfileSearchByName 超过 maxBatch 应返回错误")
}
// ProfileSearchByNameSingle存在 / 不存在 / 空名
single, err := svc.ProfileSearchByNameSingle(ctx, "a")
if err != nil || single == nil || single.ID != "a" || single.Name != "A" {
t.Fatalf("ProfileSearchByNameSingle 存在用例失败, got=%+v err=%v", single, err)
}
// 小写名应能命中大小写不敏感查询
singleLower, err := svc.ProfileSearchByNameSingle(ctx, "a")
if err != nil || singleLower == nil {
t.Fatalf("ProfileSearchByNameSingle 小写名匹配失败, got=%+v err=%v", singleLower, err)
}
// 不存在
missing, err := svc.ProfileSearchByNameSingle(ctx, "nope")
if err != nil || missing != nil {
t.Fatalf("ProfileSearchByNameSingle 不存在应返回 (nil,nil), got=%+v err=%v", missing, err)
}
// 空名
emptyName, err := svc.ProfileSearchByNameSingle(ctx, " ")
if err != nil || emptyName != nil {
t.Fatalf("ProfileSearchByNameSingle 空名应返回 (nil,nil), got=%+v err=%v", emptyName, err)
}
}