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>
This commit is contained in:
@@ -25,6 +25,9 @@ type BossRoom struct {
|
||||
BossID int `json:"bossId" gorm:"index;not null"`
|
||||
Status RoomStatus `json:"status" gorm:"type:varchar(20);index;default:waiting;not null"`
|
||||
MaxPlayers int `json:"maxPlayers" gorm:"default:3;not null"`
|
||||
// Players is stored as a JSON text column for simplicity.
|
||||
// TODO: For better query performance, consider migrating to a junction table
|
||||
// (boss_room_players with room_id + username columns).
|
||||
Players string `json:"players" gorm:"type:text"` // JSON array of usernames
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultMaxPlayers is the maximum number of players allowed in a boss raid room.
|
||||
defaultMaxPlayers = 3
|
||||
// entryTokenTTL is the TTL for boss raid entry tokens in Redis.
|
||||
entryTokenTTL = 5 * time.Minute
|
||||
// entryTokenPrefix is the Redis key prefix for entry token → {username, sessionName}.
|
||||
@@ -84,7 +86,7 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
|
||||
SessionName: sessionName,
|
||||
BossID: bossID,
|
||||
Status: StatusWaiting,
|
||||
MaxPlayers: 3,
|
||||
MaxPlayers: defaultMaxPlayers,
|
||||
Players: string(playersJSON),
|
||||
}
|
||||
|
||||
|
||||
602
internal/bossraid/service_test.go
Normal file
602
internal/bossraid/service_test.go
Normal file
@@ -0,0 +1,602 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user