Files
backend/internal/service/texture_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

858 lines
20 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"
"crypto/sha256"
"encoding/hex"
"testing"
"go.uber.org/zap"
)
// sha256Hex 计算给定数据的 SHA-256 十六进制字符串
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// TestTextureService_TypeValidation 测试材质类型验证
func TestTextureService_TypeValidation(t *testing.T) {
tests := []struct {
name string
textureType string
wantValid bool
}{
{
name: "SKIN类型有效",
textureType: "SKIN",
wantValid: true,
},
{
name: "CAPE类型有效",
textureType: "CAPE",
wantValid: true,
},
{
name: "无效类型",
textureType: "INVALID",
wantValid: false,
},
{
name: "空类型无效",
textureType: "",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.textureType == "SKIN" || tt.textureType == "CAPE"
if isValid != tt.wantValid {
t.Errorf("Texture type validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestTextureService_DefaultValues 测试材质默认值
func TestTextureService_DefaultValues(t *testing.T) {
// 测试默认状态
defaultStatus := 1
if defaultStatus != 1 {
t.Errorf("默认状态应为1实际为%d", defaultStatus)
}
// 测试默认下载数
defaultDownloadCount := 0
if defaultDownloadCount != 0 {
t.Errorf("默认下载数应为0实际为%d", defaultDownloadCount)
}
// 测试默认收藏数
defaultFavoriteCount := 0
if defaultFavoriteCount != 0 {
t.Errorf("默认收藏数应为0实际为%d", defaultFavoriteCount)
}
}
// TestTextureService_StatusValidation 测试材质状态验证
func TestTextureService_StatusValidation(t *testing.T) {
tests := []struct {
name string
status int16
wantValid bool
}{
{
name: "状态为1正常时有效",
status: 1,
wantValid: true,
},
{
name: "状态为-1删除时无效",
status: -1,
wantValid: false,
},
{
name: "状态为0时可能有效取决于业务逻辑",
status: 0,
wantValid: true, // 状态为0禁用材质仍然存在只是不可用但查询时不会返回错误
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 材质状态为-1时表示已删除无效
isValid := tt.status != -1
if isValid != tt.wantValid {
t.Errorf("Status validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestGetUserTextures_Pagination 测试分页逻辑
func TestGetUserTextures_Pagination(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
wantPage int
wantSize int
}{
{
name: "有效的分页参数",
page: 2,
pageSize: 20,
wantPage: 2,
wantSize: 20,
},
{
name: "page小于1应该设为1",
page: 0,
pageSize: 20,
wantPage: 1,
wantSize: 20,
},
{
name: "pageSize小于1应该设为20",
page: 1,
pageSize: 0,
wantPage: 1,
wantSize: 20,
},
{
name: "pageSize超过100应该设为20",
page: 1,
pageSize: 200,
wantPage: 1,
wantSize: 20,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := tt.page
pageSize := tt.pageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
if page != tt.wantPage {
t.Errorf("Page = %d, want %d", page, tt.wantPage)
}
if pageSize != tt.wantSize {
t.Errorf("PageSize = %d, want %d", pageSize, tt.wantSize)
}
})
}
}
// TestSearchTextures_Pagination 测试搜索分页逻辑
func TestSearchTextures_Pagination(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
wantPage int
wantSize int
}{
{
name: "有效的分页参数",
page: 1,
pageSize: 10,
wantPage: 1,
wantSize: 10,
},
{
name: "page小于1应该设为1",
page: -1,
pageSize: 20,
wantPage: 1,
wantSize: 20,
},
{
name: "pageSize超过100应该设为20",
page: 1,
pageSize: 150,
wantPage: 1,
wantSize: 20,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := tt.page
pageSize := tt.pageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
if page != tt.wantPage {
t.Errorf("Page = %d, want %d", page, tt.wantPage)
}
if pageSize != tt.wantSize {
t.Errorf("PageSize = %d, want %d", pageSize, tt.wantSize)
}
})
}
}
// TestUpdateTexture_PermissionCheck 测试更新材质的权限检查
func TestUpdateTexture_PermissionCheck(t *testing.T) {
tests := []struct {
name string
uploaderID int64
requestID int64
wantErr bool
}{
{
name: "上传者ID匹配允许更新",
uploaderID: 1,
requestID: 1,
wantErr: false,
},
{
name: "上传者ID不匹配拒绝更新",
uploaderID: 1,
requestID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.uploaderID != tt.requestID
if hasError != tt.wantErr {
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestUpdateTexture_FieldUpdates 测试更新字段逻辑
func TestUpdateTexture_FieldUpdates(t *testing.T) {
tests := []struct {
name string
nameValue string
descValue string
isPublic *bool
wantUpdates int
}{
{
name: "更新所有字段",
nameValue: "NewName",
descValue: "NewDesc",
isPublic: boolPtr(true),
wantUpdates: 3,
},
{
name: "只更新名称",
nameValue: "NewName",
descValue: "",
isPublic: nil,
wantUpdates: 1,
},
{
name: "只更新描述",
nameValue: "",
descValue: "NewDesc",
isPublic: nil,
wantUpdates: 1,
},
{
name: "只更新公开状态",
nameValue: "",
descValue: "",
isPublic: boolPtr(false),
wantUpdates: 1,
},
{
name: "没有更新",
nameValue: "",
descValue: "",
isPublic: nil,
wantUpdates: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
updates := 0
if tt.nameValue != "" {
updates++
}
if tt.descValue != "" {
updates++
}
if tt.isPublic != nil {
updates++
}
if updates != tt.wantUpdates {
t.Errorf("Updates count = %d, want %d", updates, tt.wantUpdates)
}
})
}
}
// TestDeleteTexture_PermissionCheck 测试删除材质的权限检查
func TestDeleteTexture_PermissionCheck(t *testing.T) {
tests := []struct {
name string
uploaderID int64
requestID int64
wantErr bool
}{
{
name: "上传者ID匹配允许删除",
uploaderID: 1,
requestID: 1,
wantErr: false,
},
{
name: "上传者ID不匹配拒绝删除",
uploaderID: 1,
requestID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.uploaderID != tt.requestID
if hasError != tt.wantErr {
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestToggleTextureFavorite_Logic 测试切换收藏状态的逻辑
func TestToggleTextureFavorite_Logic(t *testing.T) {
tests := []struct {
name string
isFavorited bool
wantResult bool
}{
{
name: "已收藏,取消收藏",
isFavorited: true,
wantResult: false,
},
{
name: "未收藏,添加收藏",
isFavorited: false,
wantResult: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := !tt.isFavorited
if result != tt.wantResult {
t.Errorf("Toggle favorite failed: got %v, want %v", result, tt.wantResult)
}
})
}
}
// TestGetUserTextureFavorites_Pagination 测试收藏列表分页
func TestGetUserTextureFavorites_Pagination(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
wantPage int
wantSize int
}{
{
name: "有效的分页参数",
page: 1,
pageSize: 20,
wantPage: 1,
wantSize: 20,
},
{
name: "page小于1应该设为1",
page: 0,
pageSize: 20,
wantPage: 1,
wantSize: 20,
},
{
name: "pageSize超过100应该设为20",
page: 1,
pageSize: 200,
wantPage: 1,
wantSize: 20,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := tt.page
pageSize := tt.pageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
if page != tt.wantPage {
t.Errorf("Page = %d, want %d", page, tt.wantPage)
}
if pageSize != tt.wantSize {
t.Errorf("PageSize = %d, want %d", pageSize, tt.wantSize)
}
})
}
}
// TestCheckTextureUploadLimit_Logic 测试上传限制检查逻辑
func TestCheckTextureUploadLimit_Logic(t *testing.T) {
tests := []struct {
name string
count int64
maxTextures int
wantErr bool
}{
{
name: "未达到上限",
count: 5,
maxTextures: 10,
wantErr: false,
},
{
name: "达到上限",
count: 10,
maxTextures: 10,
wantErr: true,
},
{
name: "超过上限",
count: 15,
maxTextures: 10,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.count >= int64(tt.maxTextures)
if hasError != tt.wantErr {
t.Errorf("Limit check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// 辅助函数
func boolPtr(b bool) *bool {
return &b
}
// ============================================================================
// 使用 Mock 的集成测试
// ============================================================================
// TestTextureServiceImpl_Create 测试创建Texture
func TestTextureServiceImpl_Create(t *testing.T) {
textureRepo := NewMockTextureRepository()
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()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
// 构造一份符合 service 大小校验(>=512B的 PNG 文件数据
pngFileData := make([]byte, 1024)
for i := range pngFileData {
pngFileData[i] = byte(i % 256)
}
// 计算其真实 SHA-256用于 mock 预置与命中分支测试
existingHash := sha256Hex(pngFileData)
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: existingHash,
URL: "https://example.com/existing.png",
})
tests := []struct {
name string
uploaderID int64
textureName string
textureType string
fileData []byte
wantErr bool
errContains string
}{
{
name: " Hash 已存在 -> 复用 URL",
uploaderID: 1,
textureName: "TestSkin",
textureType: "SKIN",
fileData: pngFileData, // hash 命中预置记录
wantErr: false,
},
{
name: "Hash 不存在且 storage 为 nil -> 存储不可用",
uploaderID: 1,
textureName: "NewCape",
textureType: "CAPE",
fileData: make([]byte, 1024), // 不同内容hash 不会命中
wantErr: true,
errContains: "存储服务不可用",
},
{
name: "用户不存在",
uploaderID: 999,
textureName: "TestTexture",
textureType: "SKIN",
fileData: pngFileData,
wantErr: true,
},
{
name: "无效的材质类型",
uploaderID: 1,
textureName: "InvalidTypeTexture",
textureType: "INVALID",
fileData: pngFileData,
wantErr: true,
errContains: "无效的材质类型",
},
{
name: "文件过小",
uploaderID: 1,
textureName: "TooSmall",
textureType: "SKIN",
fileData: []byte("tiny"),
wantErr: true,
errContains: "文件大小必须在",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
texture, err := textureService.UploadTexture(
ctx,
tt.uploaderID,
tt.textureName,
"Test description",
tt.textureType,
tt.fileData,
"test.png",
true,
false,
)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
}
return
}
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Fatal("返回的Texture不应为nil")
}
if texture.Name != tt.textureName {
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
}
})
}
}
// TestTextureServiceImpl_GetByID 测试获取Texture
func TestTextureServiceImpl_GetByID(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置Texture
testTexture := &model.Texture{
ID: 1,
UploaderID: 1,
Name: "TestTexture",
Hash: "test-hash",
}
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
tests := []struct {
name string
id int64
wantErr bool
}{
{
name: "获取存在的Texture",
id: 1,
wantErr: false,
},
{
name: "获取不存在的Texture",
id: 999,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
texture, err := textureService.GetByID(ctx, tt.id)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Error("返回的Texture不应为nil")
}
}
})
}
}
// TestTextureServiceImpl_GetByUserID_And_Search 测试 GetByUserID 与 Search 分页封装
func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置多条 Texture
for i := int64(1); i <= 5; i++ {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: i,
UploaderID: 1,
Name: "T",
IsPublic: i%2 == 0,
})
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
// GetByUserID 应按上传者过滤并调用 NormalizePagination
textures, total, err := textureService.GetByUserID(ctx, 1, 0, 0)
if err != nil {
t.Fatalf("GetByUserID 失败: %v", err)
}
if total != int64(len(textures)) {
t.Fatalf("GetByUserID 返回数量与总数不一致, total=%d, len=%d", total, len(textures))
}
// Search 仅验证能够正常调用并返回结果
searchResult, searchTotal, err := textureService.Search(ctx, "", model.TextureTypeSkin, true, -1, 200)
if err != nil {
t.Fatalf("Search 失败: %v", err)
}
if searchTotal != int64(len(searchResult)) {
t.Fatalf("Search 返回数量与总数不一致, total=%d, len=%d", searchTotal, len(searchResult))
}
}
// TestTextureServiceImpl_Update_And_Delete 测试 Update / Delete 权限与字段更新
func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
texture := &model.Texture{
ID: 1,
UploaderID: 1,
Name: "Old",
Description: "OldDesc",
IsPublic: false,
}
_ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
// 更新成功
newName := "NewName"
newDesc := "NewDesc"
public := boolPtr(true)
updated, err := textureService.Update(ctx, 1, 1, newName, newDesc, public)
if err != nil {
t.Fatalf("Update 正常情况失败: %v", err)
}
// 由于 MockTextureRepository.UpdateFields 不会真正修改结构体字段,这里只验证不会返回 nil 即可
if updated == nil {
t.Fatalf("Update 返回结果不应为 nil")
}
// 无权限更新
if _, err := textureService.Update(ctx, 1, 2, "X", "Y", nil); err == nil {
t.Fatalf("Update 在无权限时应返回错误")
}
// 删除成功
if err := textureService.Delete(ctx, 1, 1); err != nil {
t.Fatalf("Delete 正常情况失败: %v", err)
}
// 无权限删除
if err := textureService.Delete(ctx, 1, 2); err == nil {
t.Fatalf("Delete 在无权限时应返回错误")
}
}
// TestTextureServiceImpl_FavoritesAndLimit 测试 GetUserFavorites 与 CheckUploadLimit
func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置若干 Texture 与收藏关系
for i := int64(1); i <= 3; i++ {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: i,
UploaderID: 1,
Name: "T",
})
_ = textureRepo.AddFavorite(context.Background(), 1, i)
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
// GetUserFavorites
favs, total, err := textureService.GetUserFavorites(ctx, 1, -1, -1)
if err != nil {
t.Fatalf("GetUserFavorites 失败: %v", err)
}
if int64(len(favs)) != total || total != 3 {
t.Fatalf("GetUserFavorites 数量不正确, total=%d, len=%d", total, len(favs))
}
// CheckUploadLimit 未超过上限
if err := textureService.CheckUploadLimit(ctx, 1, 10); err != nil {
t.Fatalf("CheckUploadLimit 在未达到上限时不应报错: %v", err)
}
// CheckUploadLimit 超过上限
if err := textureService.CheckUploadLimit(ctx, 1, 2); err == nil {
t.Fatalf("CheckUploadLimit 在超过上限时应返回错误")
}
}
// TestTextureServiceImpl_ToggleFavorite 测试收藏功能
func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户和Texture
testUser := &model.User{ID: 1, Username: "testuser", Status: 1}
_ = userRepo.Create(context.Background(), testUser)
testTexture := &model.Texture{
ID: 1,
UploaderID: 1,
Name: "TestTexture",
Hash: "test-hash",
}
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
// 第一次收藏
isFavorited, err := textureService.ToggleFavorite(ctx, 1, 1)
if err != nil {
t.Errorf("第一次收藏失败: %v", err)
}
if !isFavorited {
t.Error("第一次操作应该是添加收藏")
}
// 第二次取消收藏
isFavorited, err = textureService.ToggleFavorite(ctx, 1, 1)
if err != nil {
t.Errorf("取消收藏失败: %v", err)
}
if isFavorited {
t.Error("第二次操作应该是取消收藏")
}
}
// 辅助函数
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) && (findSubstring(s, substr) != -1)))
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}