// 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 bossraid import ( "encoding/json" "fmt" "testing" "time" "github.com/tolelom/tolchain/core" ) // --------------------------------------------------------------------------- // Mock repository — mirrors the methods that Service calls. // Since Service uses concrete *Repository and Transaction(func(*Repository)), // we create a testableService with an interface to enable mocking. // --------------------------------------------------------------------------- type repositoryInterface interface { Create(room *BossRoom) error Update(room *BossRoom) error FindBySessionName(sessionName string) (*BossRoom, error) FindBySessionNameForUpdate(sessionName string) (*BossRoom, error) CountActiveByUsername(username string) (int64, error) Transaction(fn func(txRepo repositoryInterface) error) error } type testableService struct { repo repositoryInterface rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error } func (s *testableService) RequestEntry(usernames []string, bossID int) (*BossRoom, error) { if len(usernames) == 0 { return nil, fmt.Errorf("플레이어 목록이 비어있습니다") } if len(usernames) > 3 { return nil, fmt.Errorf("최대 3명까지 입장할 수 있습니다") } seen := make(map[string]bool, len(usernames)) for _, u := range usernames { if seen[u] { return nil, fmt.Errorf("중복된 플레이어가 있습니다: %s", u) } seen[u] = true } for _, username := range usernames { count, err := s.repo.CountActiveByUsername(username) if err != nil { return nil, fmt.Errorf("플레이어 상태 확인 실패: %w", err) } if count > 0 { return nil, fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username) } } playersJSON, err := json.Marshal(usernames) if err != nil { return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err) } sessionName := fmt.Sprintf("BossRaid_%d_%d", bossID, time.Now().UnixNano()) room := &BossRoom{ SessionName: sessionName, BossID: bossID, Status: StatusWaiting, MaxPlayers: 3, Players: string(playersJSON), } if err := s.repo.Create(room); err != nil { return nil, fmt.Errorf("방 생성 실패: %w", err) } return room, nil } func (s *testableService) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) { var resultRoom *BossRoom var resultRewards []RewardResult err := s.repo.Transaction(func(txRepo repositoryInterface) error { room, err := txRepo.FindBySessionNameForUpdate(sessionName) if err != nil { return fmt.Errorf("방을 찾을 수 없습니다: %w", err) } if room.Status != StatusInProgress { return fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status) } var players []string if err := json.Unmarshal([]byte(room.Players), &players); err != nil { return fmt.Errorf("플레이어 목록 파싱 실패: %w", err) } playerSet := make(map[string]bool, len(players)) for _, p := range players { playerSet[p] = true } for _, r := range rewards { if !playerSet[r.Username] { return fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username) } } now := time.Now() room.Status = StatusCompleted room.CompletedAt = &now if err := txRepo.Update(room); err != nil { return fmt.Errorf("상태 업데이트 실패: %w", err) } resultRoom = room return nil }) if err != nil { return nil, nil, err } resultRewards = make([]RewardResult, 0, len(rewards)) if s.rewardGrant != nil { for _, r := range rewards { grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets) result := RewardResult{Username: r.Username, Success: grantErr == nil} if grantErr != nil { result.Error = grantErr.Error() } resultRewards = append(resultRewards, result) } } return resultRoom, resultRewards, nil } func (s *testableService) FailRaid(sessionName string) (*BossRoom, error) { var resultRoom *BossRoom err := s.repo.Transaction(func(txRepo repositoryInterface) error { room, err := txRepo.FindBySessionNameForUpdate(sessionName) if err != nil { return fmt.Errorf("방을 찾을 수 없습니다: %w", err) } if room.Status != StatusWaiting && room.Status != StatusInProgress { return fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status) } now := time.Now() room.Status = StatusFailed room.CompletedAt = &now if err := txRepo.Update(room); err != nil { return fmt.Errorf("상태 업데이트 실패: %w", err) } resultRoom = room return nil }) if err != nil { return nil, err } return resultRoom, nil } // --------------------------------------------------------------------------- // Mock implementation // --------------------------------------------------------------------------- type mockRepo struct { rooms map[string]*BossRoom activeCounts map[string]int64 // username -> active count nextID uint createErr error updateErr error countActiveErr error } func newMockRepo() *mockRepo { return &mockRepo{ rooms: make(map[string]*BossRoom), activeCounts: make(map[string]int64), nextID: 1, } } func (m *mockRepo) Create(room *BossRoom) error { if m.createErr != nil { return m.createErr } room.ID = m.nextID room.CreatedAt = time.Now() room.UpdatedAt = time.Now() m.nextID++ stored := *room m.rooms[room.SessionName] = &stored return nil } func (m *mockRepo) Update(room *BossRoom) error { if m.updateErr != nil { return m.updateErr } room.UpdatedAt = time.Now() stored := *room m.rooms[room.SessionName] = &stored return nil } func (m *mockRepo) FindBySessionName(sessionName string) (*BossRoom, error) { room, ok := m.rooms[sessionName] if !ok { return nil, fmt.Errorf("record not found") } cp := *room return &cp, nil } func (m *mockRepo) FindBySessionNameForUpdate(sessionName string) (*BossRoom, error) { return m.FindBySessionName(sessionName) } func (m *mockRepo) CountActiveByUsername(username string) (int64, error) { if m.countActiveErr != nil { return 0, m.countActiveErr } return m.activeCounts[username], nil } func (m *mockRepo) Transaction(fn func(txRepo repositoryInterface) error) error { return fn(m) } // --------------------------------------------------------------------------- // Tests: RequestEntry // --------------------------------------------------------------------------- func TestRequestEntry_Success(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, err := svc.RequestEntry([]string{"player1", "player2"}, 1) if err != nil { t.Fatalf("RequestEntry failed: %v", err) } if room.Status != StatusWaiting { t.Errorf("Status = %q, want %q", room.Status, StatusWaiting) } if room.BossID != 1 { t.Errorf("BossID = %d, want 1", room.BossID) } if room.MaxPlayers != 3 { t.Errorf("MaxPlayers = %d, want 3", room.MaxPlayers) } } func TestRequestEntry_EmptyPlayers(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} _, err := svc.RequestEntry([]string{}, 1) if err == nil { t.Error("expected error for empty player list, got nil") } } func TestRequestEntry_TooManyPlayers(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} _, err := svc.RequestEntry([]string{"p1", "p2", "p3", "p4"}, 1) if err == nil { t.Error("expected error for >3 players, got nil") } } func TestRequestEntry_DuplicatePlayers(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} _, err := svc.RequestEntry([]string{"player1", "player1"}, 1) if err == nil { t.Error("expected error for duplicate players, got nil") } } func TestRequestEntry_PlayerAlreadyInActiveRaid(t *testing.T) { repo := newMockRepo() repo.activeCounts["player1"] = 1 svc := &testableService{repo: repo} _, err := svc.RequestEntry([]string{"player1", "player2"}, 1) if err == nil { t.Error("expected error when player is already in active raid, got nil") } } func TestRequestEntry_ThreePlayers(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, err := svc.RequestEntry([]string{"p1", "p2", "p3"}, 5) if err != nil { t.Fatalf("RequestEntry failed: %v", err) } var players []string if err := json.Unmarshal([]byte(room.Players), &players); err != nil { t.Fatalf("failed to parse Players JSON: %v", err) } if len(players) != 3 { t.Errorf("expected 3 players in JSON, got %d", len(players)) } } // --------------------------------------------------------------------------- // Tests: CompleteRaid // --------------------------------------------------------------------------- func TestCompleteRaid_Success(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} // Create a room and set it to in_progress room, _ := svc.RequestEntry([]string{"player1", "player2"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusInProgress now := time.Now() stored.StartedAt = &now rewards := []PlayerReward{ {Username: "player1", TokenAmount: 100}, {Username: "player2", TokenAmount: 50}, } completed, results, err := svc.CompleteRaid(room.SessionName, rewards) if err != nil { t.Fatalf("CompleteRaid failed: %v", err) } if completed.Status != StatusCompleted { t.Errorf("Status = %q, want %q", completed.Status, StatusCompleted) } if completed.CompletedAt == nil { t.Error("CompletedAt should be set") } // No reward granter set, so results should be empty if len(results) != 0 { t.Errorf("expected 0 reward results (no granter set), got %d", len(results)) } } func TestCompleteRaid_WithRewardGranter(t *testing.T) { repo := newMockRepo() grantCalls := 0 svc := &testableService{ repo: repo, rewardGrant: func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { grantCalls++ return nil }, } room, _ := svc.RequestEntry([]string{"player1"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusInProgress rewards := []PlayerReward{ {Username: "player1", TokenAmount: 100}, } _, results, err := svc.CompleteRaid(room.SessionName, rewards) if err != nil { t.Fatalf("CompleteRaid failed: %v", err) } if grantCalls != 1 { t.Errorf("expected 1 grant call, got %d", grantCalls) } if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } if !results[0].Success { t.Errorf("expected success=true, got false") } } func TestCompleteRaid_RewardGranterFails(t *testing.T) { repo := newMockRepo() svc := &testableService{ repo: repo, rewardGrant: func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { return fmt.Errorf("blockchain error") }, } room, _ := svc.RequestEntry([]string{"player1"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusInProgress rewards := []PlayerReward{ {Username: "player1", TokenAmount: 100}, } completed, results, err := svc.CompleteRaid(room.SessionName, rewards) if err != nil { t.Fatalf("CompleteRaid should not fail when reward granter fails: %v", err) } // Room should still be completed if completed.Status != StatusCompleted { t.Errorf("Status = %q, want %q", completed.Status, StatusCompleted) } if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } if results[0].Success { t.Error("expected success=false for failed grant") } if results[0].Error == "" { t.Error("expected non-empty error message") } } func TestCompleteRaid_WrongStatus(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"player1"}, 1) // Room is in "waiting" status, not "in_progress" _, _, err := svc.CompleteRaid(room.SessionName, nil) if err == nil { t.Error("expected error completing raid that is not in_progress, got nil") } } func TestCompleteRaid_InvalidRewardRecipient(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"player1"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusInProgress rewards := []PlayerReward{ {Username: "not_a_member", TokenAmount: 100}, } _, _, err := svc.CompleteRaid(room.SessionName, rewards) if err == nil { t.Error("expected error for reward to non-member, got nil") } } func TestCompleteRaid_RoomNotFound(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} _, _, err := svc.CompleteRaid("nonexistent_session", nil) if err == nil { t.Error("expected error for non-existent room, got nil") } } // --------------------------------------------------------------------------- // Tests: FailRaid // --------------------------------------------------------------------------- func TestFailRaid_FromWaiting(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"player1"}, 1) failed, err := svc.FailRaid(room.SessionName) if err != nil { t.Fatalf("FailRaid failed: %v", err) } if failed.Status != StatusFailed { t.Errorf("Status = %q, want %q", failed.Status, StatusFailed) } if failed.CompletedAt == nil { t.Error("CompletedAt should be set on failure") } } func TestFailRaid_FromInProgress(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"player1"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusInProgress failed, err := svc.FailRaid(room.SessionName) if err != nil { t.Fatalf("FailRaid failed: %v", err) } if failed.Status != StatusFailed { t.Errorf("Status = %q, want %q", failed.Status, StatusFailed) } } func TestFailRaid_FromCompleted(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"player1"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusCompleted _, err := svc.FailRaid(room.SessionName) if err == nil { t.Error("expected error failing already-completed raid, got nil") } } func TestFailRaid_FromFailed(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"player1"}, 1) stored := repo.rooms[room.SessionName] stored.Status = StatusFailed _, err := svc.FailRaid(room.SessionName) if err == nil { t.Error("expected error failing already-failed raid, got nil") } } func TestFailRaid_RoomNotFound(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} _, err := svc.FailRaid("nonexistent_session") if err == nil { t.Error("expected error for non-existent room, got nil") } } // --------------------------------------------------------------------------- // Tests: State machine transitions // --------------------------------------------------------------------------- func TestStateMachine_FullLifecycle(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} // 1. Create room (waiting) room, err := svc.RequestEntry([]string{"p1", "p2"}, 1) if err != nil { t.Fatalf("RequestEntry failed: %v", err) } if room.Status != StatusWaiting { t.Fatalf("expected waiting, got %s", room.Status) } // 2. Simulate start (set to in_progress) stored := repo.rooms[room.SessionName] stored.Status = StatusInProgress now := time.Now() stored.StartedAt = &now // 3. Complete completed, _, err := svc.CompleteRaid(room.SessionName, []PlayerReward{ {Username: "p1", TokenAmount: 10}, }) if err != nil { t.Fatalf("CompleteRaid failed: %v", err) } if completed.Status != StatusCompleted { t.Errorf("expected completed, got %s", completed.Status) } // 4. Cannot fail a completed raid _, err = svc.FailRaid(room.SessionName) if err == nil { t.Error("expected error failing completed raid") } } func TestStateMachine_WaitingToFailed(t *testing.T) { repo := newMockRepo() svc := &testableService{repo: repo} room, _ := svc.RequestEntry([]string{"p1"}, 1) failed, err := svc.FailRaid(room.SessionName) if err != nil { t.Fatalf("FailRaid failed: %v", err) } if failed.Status != StatusFailed { t.Errorf("expected failed, got %s", failed.Status) } // Cannot complete a failed raid stored := repo.rooms[room.SessionName] stored.Status = StatusFailed _, _, err = svc.CompleteRaid(room.SessionName, nil) if err == nil { t.Error("expected error completing failed raid") } }