// 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 { profile, err := s.repo.FindByUserID(userID) if err != nil { return fmt.Errorf("프로필이 존재하지 않습니다") } updates["total_play_time"] = profile.TotalPlayTime + *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) } } 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") } }