Files
a301_server/internal/bossraid/service_test.go
tolelom b0de89a18a
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 47s
Server CI/CD / deploy (push) Has been skipped
feat: 코드 리뷰 기반 전면 개선 — 보안, 검증, 테스트, 안정성
- 체인 nonce 경쟁 조건 수정 (operatorMu + per-user mutex)
- 등록/SSAFY 원자적 트랜잭션 (wallet+profile 롤백 보장)
- IdempotencyRequired 미들웨어 (SETNX 원자적 클레임)
- 런치 티켓 API (JWT URL 노출 방지)
- HttpOnly 쿠키 refresh token
- SSAFY OAuth state 파라미터 (CSRF 방지)
- Refresh 시 DB 조회로 최신 role 사용
- 공지사항/유저목록 페이지네이션
- BodyLimit 미들웨어 (1MB, upload 제외)
- 입력 검증 강화 (닉네임, 게임데이터, 공지 길이)
- 에러 메시지 내부 정보 노출 방지
- io.LimitReader (RPC 10MB, SSAFY 1MB)
- RequestID 비출력 문자 제거
- 단위 테스트 (auth 11, announcement 9, bossraid 16)

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

603 lines
16 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 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")
}
}