- 체인 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>
603 lines
16 KiB
Go
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")
|
|
}
|
|
}
|