feat: 보상 재시도 + TX 확정 대기 + 에러 포맷 통일 + 품질 고도화
- 보상 지급 실패 시 즉시 재시도(3회 backoff) + DB 기록 + 백그라운드 워커 재시도 - WaitForTx 폴링으로 블록체인 TX 확정 대기, SendTxAndWait 편의 메서드 - chain 트랜잭션 코드 중복 제거 (userTx/operatorTx 헬퍼, 50% 감소) - AppError 기반 에러 응답 포맷 통일 (8개 코드, 전 핸들러 마이그레이션) - TX 에러 분류 + 한국어 사용자 메시지 매핑 (11가지 패턴) - player 서비스 테스트 20개 + chain WaitForTx 테스트 10개 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
542
internal/player/service_test.go
Normal file
542
internal/player/service_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user