- 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>
544 lines
14 KiB
Go
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")
|
|
}
|
|
}
|