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

261 lines
8.4 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 (
"context"
"testing"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/utils"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// newFriendsTestDB 构建内存 sqlite 并迁移所需表
func newFriendsTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Profile{}, &model.Friend{}, &model.PlayerAttributes{}); err != nil {
t.Fatalf("migrate: %v", err)
}
// 清理共享内存库的残留数据
db.Exec("DELETE FROM friends")
db.Exec("DELETE FROM profiles")
db.Exec("DELETE FROM player_attributes")
return db
}
// newFriendsService 用真实 GORM repo + nil redis 构造服务presence/blocklist 缓存路径会跳过)
func newFriendsService(t *testing.T) (FriendsService, *gorm.DB) {
t.Helper()
db := newFriendsTestDB(t)
friendRepo := repository.NewFriendRepository(db)
attrRepo := repository.NewPlayerAttributeRepository(db)
profileRepo := repository.NewProfileRepository(db)
return NewFriendsService(friendRepo, attrRepo, profileRepo, nil, zap.NewNop()), db
}
func mustCreateProfile(t *testing.T, db *gorm.DB, name string) *model.Profile {
t.Helper()
p := &model.Profile{
UUID: utils.GenerateUUID(),
UserID: 1,
Name: name,
RSAPrivateKey: "x",
}
if err := db.Create(p).Error; err != nil {
t.Fatalf("create profile %s: %v", name, err)
}
return p
}
// TestAddFriend_SelfReject 添加自己应报错
func TestAddFriend_SelfReject(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "self")
_, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{
ProfileID: p.UUID,
UpdateType: FriendActionAdd,
})
if err == nil {
t.Fatal("添加自己应报错")
}
}
// TestAddFriend_UnknownProfile 目标不存在应报错
func TestAddFriend_UnknownProfile(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "a")
// 使用 32 位无连字符 UUID规避 FormatUUIDToNoDash 对非法长度的告警路径
_, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{
ProfileID: "ffffffffffffffffffffffffffffffff",
UpdateType: FriendActionAdd,
})
if err == nil {
t.Fatal("不存在的目标应报错")
}
}
// TestAddFriend_SendRequestThenAccept 双向流程A 发请求 -> B 接受 -> 互为好友
func TestAddFriend_SendRequestThenAccept(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
// A 向 B 发起请求
resp, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{
ProfileID: b.UUID,
UpdateType: FriendActionAdd,
})
if err != nil {
t.Fatalf("A 发起请求失败: %v", err)
}
if len(resp.OutgoingRequests) != 1 || resp.OutgoingRequests[0].ProfileID != b.UUID {
t.Fatalf("A 的 outgoing 应包含 B, got %+v", resp.OutgoingRequests)
}
// B 视角incoming 应包含 A
respB, err := svc.ListFriends(context.Background(), b.UUID)
if err != nil {
t.Fatalf("B 查询列表失败: %v", err)
}
if len(respB.IncomingRequests) != 1 || respB.IncomingRequests[0].ProfileID != a.UUID {
t.Fatalf("B 的 incoming 应包含 A, got %+v", respB.IncomingRequests)
}
// B 接受请求B 用 ADD 指向 A -> 自动接受)
resp, err = svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{
ProfileID: a.UUID,
UpdateType: FriendActionAdd,
})
if err != nil {
t.Fatalf("B 接受请求失败: %v", err)
}
if len(resp.Friends) != 1 || resp.Friends[0].ProfileID != a.UUID {
t.Fatalf("B 的 friends 应包含 A, got %+v", resp.Friends)
}
// A 视角:也是好友
respA, err := svc.ListFriends(context.Background(), a.UUID)
if err != nil {
t.Fatalf("A 查询列表失败: %v", err)
}
if len(respA.Friends) != 1 || respA.Friends[0].ProfileID != b.UUID {
t.Fatalf("A 的 friends 应包含 B, got %+v", respA.Friends)
}
}
// TestRemoveFriend_Flow A B 互为好友后 A 删除好友 -> 双方都无好友
func TestRemoveFriend_Flow(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
// 互为好友
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil {
t.Fatal(err)
}
if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionAdd}); err != nil {
t.Fatal(err)
}
// A 删除好友
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionRemove}); err != nil {
t.Fatalf("A 删除好友失败: %v", err)
}
// B 视角应不再有好友
respB, err := svc.ListFriends(context.Background(), b.UUID)
if err != nil {
t.Fatal(err)
}
if len(respB.Friends) != 0 {
t.Fatalf("B 应无好友, got %+v", respB.Friends)
}
}
// TestDeclineIncomingRequest B 拒绝 A 的请求 -> A 的 outgoing 清空
func TestDeclineIncomingRequest(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil {
t.Fatal(err)
}
// B 拒绝B 用 REMOVE 指向 A
if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionRemove}); err != nil {
t.Fatalf("B 拒绝失败: %v", err)
}
// A 的 outgoing 应清空
respA, err := svc.ListFriends(context.Background(), a.UUID)
if err != nil {
t.Fatal(err)
}
if len(respA.OutgoingRequests) != 0 {
t.Fatalf("A 的 outgoing 应已清空, got %+v", respA.OutgoingRequests)
}
}
// TestGetAttributes_Default 默认属性
func TestGetAttributes_Default(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "a")
resp, err := svc.GetAttributes(context.Background(), p.UUID)
if err != nil {
t.Fatal(err)
}
if !resp.ProfanityFilterPreferences.ProfanityFilterOn {
t.Error("默认应启用脏词过滤")
}
if resp.FriendsPreferences.Friends != "ENABLED" {
t.Errorf("默认 friends = %q, want ENABLED", resp.FriendsPreferences.Friends)
}
}
// TestUpdateAttributes_PartialUpdate 部分更新保留其它字段
func TestUpdateAttributes_PartialUpdate(t *testing.T) {
svc, db := newFriendsService(t)
p := mustCreateProfile(t, db, "a")
off := false
if err := svc.UpdateAttributes(context.Background(), p.UUID, PlayerAttributesRequest{
ProfanityFilterPreferences: &ProfanityFilterPreferences{ProfanityFilterOn: &off},
}); err != nil {
t.Fatalf("UpdateAttributes err: %v", err)
}
var row model.PlayerAttributes
if err := db.First(&row, "profile_uuid = ?", p.UUID).Error; err != nil {
t.Fatalf("查询 row 失败: %v", err)
}
t.Logf("DB row: %+v", row)
resp, err := svc.GetAttributes(context.Background(), p.UUID)
if err != nil {
t.Fatalf("GetAttributes err: %v", err)
}
if resp.ProfanityFilterPreferences.ProfanityFilterOn {
t.Error("脏词过滤应已关闭")
}
if resp.FriendsPreferences.Friends != "ENABLED" {
t.Errorf("friends 应保留 ENABLED, got %q", resp.FriendsPreferences.Friends)
}
}
// TestBlocklist_BlockUnblock 屏蔽/取消屏蔽
func TestBlocklist_BlockUnblock(t *testing.T) {
svc, db := newFriendsService(t)
a := mustCreateProfile(t, db, "alice")
b := mustCreateProfile(t, db, "bob")
// 使用类型断言访问 Block/Unblock接口未暴露但实现有
fs := svc.(*friendsService)
if err := fs.Block(context.Background(), a.UUID, b.UUID); err != nil {
t.Fatalf("屏蔽失败: %v", err)
}
resp, err := svc.GetBlocklist(context.Background(), a.UUID)
if err != nil {
t.Fatal(err)
}
if len(resp.BlockedProfiles) != 1 || resp.BlockedProfiles[0] != b.UUID {
t.Fatalf("屏蔽列表应含 B, got %+v", resp.BlockedProfiles)
}
if err := fs.Unblock(context.Background(), a.UUID, b.UUID); err != nil {
t.Fatalf("取消屏蔽失败: %v", err)
}
resp, err = svc.GetBlocklist(context.Background(), a.UUID)
if err != nil {
t.Fatal(err)
}
if len(resp.BlockedProfiles) != 0 {
t.Fatalf("屏蔽列表应空, got %+v", resp.BlockedProfiles)
}
}