Files
a301_server/internal/player/service_test.go
tolelom 0dfa744c16 feat: DB DI 전환 + download 하위 호환성 + race condition 수정
- middleware(Auth, Idempotency)를 클로저 팩토리 패턴으로 DI 전환
- database.DB/RDB 전역 변수 제거, ConnectMySQL/Redis 값 반환으로 변경
- download API X-API-Version 헤더 + 하위 호환성 규칙 문서화
- SaveGameData PlayTimeDelta 원자적 UPDATE (race condition 해소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:58:36 +09:00

544 lines
14 KiB
Go

// NOTE: These tests use a testableService that reimplements service logic
// with mock repositories. This means tests can pass even if the real service
// diverges. For full coverage, consider refactoring services to use repository
// interfaces so the real service can be tested with mock repositories injected.
package player
import (
"fmt"
"testing"
"gorm.io/gorm"
)
// ---------------------------------------------------------------------------
// Mock repository — mirrors the methods that Service calls on *Repository.
// ---------------------------------------------------------------------------
type repositoryInterface interface {
Create(profile *PlayerProfile) error
FindByUserID(userID uint) (*PlayerProfile, error)
Update(profile *PlayerProfile) error
UpdateStats(userID uint, updates map[string]interface{}) error
}
type testableService struct {
repo repositoryInterface
userResolver func(username string) (uint, error)
}
func (s *testableService) GetProfile(userID uint) (*PlayerProfile, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
profile = &PlayerProfile{UserID: userID}
if createErr := s.repo.Create(profile); createErr != nil {
return nil, fmt.Errorf("프로필 자동 생성에 실패했습니다: %w", createErr)
}
return profile, nil
}
return nil, fmt.Errorf("프로필 조회에 실패했습니다")
}
return profile, nil
}
func (s *testableService) UpdateProfile(userID uint, nickname string) (*PlayerProfile, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("프로필이 존재하지 않습니다")
}
if nickname != "" {
profile.Nickname = nickname
}
if err := s.repo.Update(profile); err != nil {
return nil, fmt.Errorf("프로필 수정에 실패했습니다")
}
return profile, nil
}
func (s *testableService) SaveGameData(userID uint, data *GameDataRequest) error {
if err := validateGameData(data); err != nil {
return err
}
updates := map[string]interface{}{}
if data.Level != nil {
updates["level"] = *data.Level
}
if data.Experience != nil {
updates["experience"] = *data.Experience
}
if data.MaxHP != nil {
updates["max_hp"] = *data.MaxHP
}
if data.MaxMP != nil {
updates["max_mp"] = *data.MaxMP
}
if data.AttackPower != nil {
updates["attack_power"] = *data.AttackPower
}
if data.AttackRange != nil {
updates["attack_range"] = *data.AttackRange
}
if data.SprintMultiplier != nil {
updates["sprint_multiplier"] = *data.SprintMultiplier
}
if data.LastPosX != nil {
updates["last_pos_x"] = *data.LastPosX
}
if data.LastPosY != nil {
updates["last_pos_y"] = *data.LastPosY
}
if data.LastPosZ != nil {
updates["last_pos_z"] = *data.LastPosZ
}
if data.LastRotY != nil {
updates["last_rot_y"] = *data.LastRotY
}
if data.PlayTimeDelta != nil {
// Mirror the real service: atomic increment via delta value.
// The mock UpdateStats handles this by adding to the existing value.
updates["total_play_time_delta"] = *data.PlayTimeDelta
}
if len(updates) == 0 {
return nil
}
return s.repo.UpdateStats(userID, updates)
}
func (s *testableService) GetProfileByUsername(username string) (*PlayerProfile, error) {
if s.userResolver == nil {
return nil, fmt.Errorf("userResolver가 설정되지 않았습니다")
}
userID, err := s.userResolver(username)
if err != nil {
return nil, fmt.Errorf("존재하지 않는 유저입니다")
}
return s.GetProfile(userID)
}
// ---------------------------------------------------------------------------
// Mock implementation
// ---------------------------------------------------------------------------
type mockRepo struct {
profiles map[uint]*PlayerProfile
nextID uint
createErr error
updateErr error
updateStatsErr error
}
func newMockRepo() *mockRepo {
return &mockRepo{
profiles: make(map[uint]*PlayerProfile),
nextID: 1,
}
}
func (m *mockRepo) Create(profile *PlayerProfile) error {
if m.createErr != nil {
return m.createErr
}
profile.ID = m.nextID
profile.Level = 1
profile.MaxHP = 100
profile.MaxMP = 50
profile.AttackPower = 10
profile.AttackRange = 3
profile.SprintMultiplier = 1.8
m.nextID++
stored := *profile
m.profiles[profile.UserID] = &stored
return nil
}
func (m *mockRepo) FindByUserID(userID uint) (*PlayerProfile, error) {
p, ok := m.profiles[userID]
if !ok {
return nil, gorm.ErrRecordNotFound
}
cp := *p
return &cp, nil
}
func (m *mockRepo) Update(profile *PlayerProfile) error {
if m.updateErr != nil {
return m.updateErr
}
stored := *profile
m.profiles[profile.UserID] = &stored
return nil
}
func (m *mockRepo) UpdateStats(userID uint, updates map[string]interface{}) error {
if m.updateStatsErr != nil {
return m.updateStatsErr
}
p, ok := m.profiles[userID]
if !ok {
return gorm.ErrRecordNotFound
}
for key, val := range updates {
switch key {
case "level":
p.Level = val.(int)
case "experience":
p.Experience = val.(int)
case "max_hp":
p.MaxHP = val.(float64)
case "max_mp":
p.MaxMP = val.(float64)
case "attack_power":
p.AttackPower = val.(float64)
case "attack_range":
p.AttackRange = val.(float64)
case "sprint_multiplier":
p.SprintMultiplier = val.(float64)
case "last_pos_x":
p.LastPosX = val.(float64)
case "last_pos_y":
p.LastPosY = val.(float64)
case "last_pos_z":
p.LastPosZ = val.(float64)
case "last_rot_y":
p.LastRotY = val.(float64)
case "total_play_time":
p.TotalPlayTime = val.(int64)
case "total_play_time_delta":
// Simulates SQL: total_play_time = total_play_time + delta
p.TotalPlayTime += val.(int64)
}
}
return nil
}
// seedProfile creates a profile for a given userID in the mock repo.
func seedProfile(repo *mockRepo, userID uint, nickname string) *PlayerProfile {
p := &PlayerProfile{UserID: userID, Nickname: nickname}
_ = repo.Create(p)
return repo.profiles[userID]
}
// ---------------------------------------------------------------------------
// Tests — GetProfile
// ---------------------------------------------------------------------------
func TestGetProfile_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
profile, err := svc.GetProfile(1)
if err != nil {
t.Fatalf("GetProfile failed: %v", err)
}
if profile.UserID != 1 {
t.Errorf("UserID = %d, want 1", profile.UserID)
}
if profile.Nickname != "player1" {
t.Errorf("Nickname = %q, want %q", profile.Nickname, "player1")
}
}
func TestGetProfile_NotFound_AutoCreates(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
profile, err := svc.GetProfile(42)
if err != nil {
t.Fatalf("GetProfile should auto-create, got error: %v", err)
}
if profile.UserID != 42 {
t.Errorf("UserID = %d, want 42", profile.UserID)
}
if profile.Level != 1 {
t.Errorf("Level = %d, want 1 (default)", profile.Level)
}
}
func TestGetProfile_AutoCreateFails(t *testing.T) {
repo := newMockRepo()
repo.createErr = fmt.Errorf("db error")
svc := &testableService{repo: repo}
_, err := svc.GetProfile(42)
if err == nil {
t.Error("expected error when auto-create fails, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — GetProfileByUsername
// ---------------------------------------------------------------------------
func TestGetProfileByUsername_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{
repo: repo,
userResolver: func(username string) (uint, error) {
if username == "testuser" {
return 1, nil
}
return 0, fmt.Errorf("not found")
},
}
seedProfile(repo, 1, "testuser")
profile, err := svc.GetProfileByUsername("testuser")
if err != nil {
t.Fatalf("GetProfileByUsername failed: %v", err)
}
if profile.UserID != 1 {
t.Errorf("UserID = %d, want 1", profile.UserID)
}
}
func TestGetProfileByUsername_UserNotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{
repo: repo,
userResolver: func(username string) (uint, error) {
return 0, fmt.Errorf("not found")
},
}
_, err := svc.GetProfileByUsername("unknown")
if err == nil {
t.Error("expected error for unknown username, got nil")
}
}
func TestGetProfileByUsername_NoResolver(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
_, err := svc.GetProfileByUsername("anyone")
if err == nil {
t.Error("expected error when userResolver is nil, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — UpdateProfile
// ---------------------------------------------------------------------------
func TestUpdateProfile_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "old_nick")
profile, err := svc.UpdateProfile(1, "new_nick")
if err != nil {
t.Fatalf("UpdateProfile failed: %v", err)
}
if profile.Nickname != "new_nick" {
t.Errorf("Nickname = %q, want %q", profile.Nickname, "new_nick")
}
}
func TestUpdateProfile_EmptyNickname_KeepsExisting(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "keep_me")
profile, err := svc.UpdateProfile(1, "")
if err != nil {
t.Fatalf("UpdateProfile failed: %v", err)
}
if profile.Nickname != "keep_me" {
t.Errorf("Nickname = %q, want %q (should be unchanged)", profile.Nickname, "keep_me")
}
}
func TestUpdateProfile_NotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
_, err := svc.UpdateProfile(999, "nick")
if err == nil {
t.Error("expected error updating non-existent profile, got nil")
}
}
func TestUpdateProfile_RepoError(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "nick")
repo.updateErr = fmt.Errorf("db error")
_, err := svc.UpdateProfile(1, "new_nick")
if err == nil {
t.Error("expected error when repo update fails, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — SaveGameData
// ---------------------------------------------------------------------------
func intPtr(v int) *int { return &v }
func float64Ptr(v float64) *float64 { return &v }
func int64Ptr(v int64) *int64 { return &v }
func TestSaveGameData_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{
Level: intPtr(5),
Experience: intPtr(200),
MaxHP: float64Ptr(150),
LastPosX: float64Ptr(10.5),
LastPosY: float64Ptr(20.0),
LastPosZ: float64Ptr(30.0),
})
if err != nil {
t.Fatalf("SaveGameData failed: %v", err)
}
p := repo.profiles[1]
if p.Level != 5 {
t.Errorf("Level = %d, want 5", p.Level)
}
if p.Experience != 200 {
t.Errorf("Experience = %d, want 200", p.Experience)
}
if p.MaxHP != 150 {
t.Errorf("MaxHP = %f, want 150", p.MaxHP)
}
if p.LastPosX != 10.5 {
t.Errorf("LastPosX = %f, want 10.5", p.LastPosX)
}
}
func TestSaveGameData_EmptyRequest(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{})
if err != nil {
t.Fatalf("SaveGameData with empty request should succeed: %v", err)
}
}
func TestSaveGameData_PlayTimeDelta(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
repo.profiles[1].TotalPlayTime = 1000
err := svc.SaveGameData(1, &GameDataRequest{
PlayTimeDelta: int64Ptr(300),
})
if err != nil {
t.Fatalf("SaveGameData failed: %v", err)
}
p := repo.profiles[1]
if p.TotalPlayTime != 1300 {
t.Errorf("TotalPlayTime = %d, want 1300 (1000+300)", p.TotalPlayTime)
}
}
func TestSaveGameData_PlayTimeDelta_Accumulates(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
_ = svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(100)})
_ = svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(200)})
p := repo.profiles[1]
if p.TotalPlayTime != 300 {
t.Errorf("TotalPlayTime = %d, want 300 (0+100+200)", p.TotalPlayTime)
}
}
func TestSaveGameData_PlayTimeDelta_ProfileNotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
err := svc.SaveGameData(999, &GameDataRequest{PlayTimeDelta: int64Ptr(100)})
if err == nil {
t.Error("expected error when profile not found for PlayTimeDelta, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — SaveGameData validation
// ---------------------------------------------------------------------------
func TestSaveGameData_InvalidLevel(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{Level: intPtr(0)})
if err == nil {
t.Error("expected error for level=0, got nil")
}
err = svc.SaveGameData(1, &GameDataRequest{Level: intPtr(1000)})
if err == nil {
t.Error("expected error for level=1000, got nil")
}
}
func TestSaveGameData_NegativeExperience(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{Experience: intPtr(-1)})
if err == nil {
t.Error("expected error for negative experience, got nil")
}
}
func TestSaveGameData_NegativePlayTimeDelta(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(-100)})
if err == nil {
t.Error("expected error for negative playTimeDelta, got nil")
}
}
func TestSaveGameData_MaxHP_OutOfRange(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{MaxHP: float64Ptr(0)})
if err == nil {
t.Error("expected error for maxHP=0, got nil")
}
err = svc.SaveGameData(1, &GameDataRequest{MaxHP: float64Ptr(1000000)})
if err == nil {
t.Error("expected error for maxHP=1000000, got nil")
}
}
func TestSaveGameData_RepoError(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
repo.updateStatsErr = fmt.Errorf("db write error")
err := svc.SaveGameData(1, &GameDataRequest{Level: intPtr(5)})
if err == nil {
t.Error("expected error when repo UpdateStats fails, got nil")
}
}