Files
a301_server/internal/bossraid/service_test.go
tolelom 844a5b264b feat: 보안 수정 + Prometheus 메트릭 + 단위 테스트 추가
보안:
- Zip Bomb 방어 (io.LimitReader 100MB)
- Redis Del 에러 로깅 (auth, idempotency)
- 로그인 실패 로그에서 username 제거
- os.Remove 에러 로깅

모니터링:
- Prometheus 메트릭 미들웨어 + /metrics 엔드포인트
- http_requests_total, http_request_duration_seconds 등 4개 메트릭

테스트:
- download (11), chain (10), bossraid (20) = 41개 단위 테스트

기타:
- DB 모델 GORM 인덱스 태그 추가
- launcherHash 필드 + hashFileToHex() 추가

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

575 lines
15 KiB
Go

package bossraid
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/tolelom/tolchain/core"
)
// ---------------------------------------------------------------------------
// Tests for pure functions and validation logic
// ---------------------------------------------------------------------------
func TestGenerateToken_Uniqueness(t *testing.T) {
tokens := make(map[string]bool, 100)
for i := 0; i < 100; i++ {
tok, err := generateToken()
if err != nil {
t.Fatalf("generateToken() failed: %v", err)
}
if len(tok) != 64 { // 32 bytes = 64 hex chars
t.Errorf("token length = %d, want 64", len(tok))
}
if tokens[tok] {
t.Errorf("duplicate token generated: %s", tok)
}
tokens[tok] = true
}
}
func TestGenerateToken_IsValidHex(t *testing.T) {
tok, err := generateToken()
if err != nil {
t.Fatalf("generateToken() failed: %v", err)
}
for _, c := range tok {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("token contains non-hex char: %c", c)
}
}
}
// ---------------------------------------------------------------------------
// Tests for RegisterServer input validation
// Note: RequestEntry calls CheckStaleSlots() before validation, which needs
// a non-nil repo, so we test its validation via the mock-based tests below.
// RegisterServer validates before DB access, so we can test directly.
// ---------------------------------------------------------------------------
func TestRegisterServer_Validation_EmptyServerName(t *testing.T) {
svc := &Service{}
_, err := svc.RegisterServer("", "instance1", 10)
if err == nil {
t.Error("RegisterServer with empty serverName should fail")
}
}
func TestRegisterServer_Validation_EmptyInstanceID(t *testing.T) {
svc := &Service{}
_, err := svc.RegisterServer("Dedi1", "", 10)
if err == nil {
t.Error("RegisterServer with empty instanceID should fail")
}
}
// ---------------------------------------------------------------------------
// Tests for model constants and JSON serialization
// ---------------------------------------------------------------------------
func TestRoomStatus_Constants(t *testing.T) {
tests := []struct {
status RoomStatus
want string
}{
{StatusWaiting, "waiting"},
{StatusInProgress, "in_progress"},
{StatusCompleted, "completed"},
{StatusFailed, "failed"},
{StatusRewardFailed, "reward_failed"},
}
for _, tt := range tests {
if string(tt.status) != tt.want {
t.Errorf("status %v = %q, want %q", tt.status, string(tt.status), tt.want)
}
}
}
func TestSlotStatus_Constants(t *testing.T) {
tests := []struct {
status SlotStatus
want string
}{
{SlotIdle, "idle"},
{SlotWaiting, "waiting"},
{SlotInProgress, "in_progress"},
}
for _, tt := range tests {
if string(tt.status) != tt.want {
t.Errorf("slot status %v = %q, want %q", tt.status, string(tt.status), tt.want)
}
}
}
func TestDefaultMaxPlayers(t *testing.T) {
if defaultMaxPlayers != 3 {
t.Errorf("defaultMaxPlayers = %d, want 3", defaultMaxPlayers)
}
}
func TestBossRoom_PlayersJSON_RoundTrip(t *testing.T) {
usernames := []string{"alice", "bob", "charlie"}
data, err := json.Marshal(usernames)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
room := BossRoom{
Players: string(data),
}
var parsed []string
if err := json.Unmarshal([]byte(room.Players), &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(parsed) != 3 {
t.Fatalf("parsed player count = %d, want 3", len(parsed))
}
for i, want := range usernames {
if parsed[i] != want {
t.Errorf("parsed[%d] = %q, want %q", i, parsed[i], want)
}
}
}
func TestPlayerReward_JSONRoundTrip(t *testing.T) {
rewards := []PlayerReward{
{Username: "alice", TokenAmount: 100, Experience: 50},
{Username: "bob", TokenAmount: 200, Experience: 75, Assets: nil},
}
data, err := json.Marshal(rewards)
if err != nil {
t.Fatalf("marshal rewards failed: %v", err)
}
var parsed []PlayerReward
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal rewards failed: %v", err)
}
if len(parsed) != 2 {
t.Fatalf("parsed reward count = %d, want 2", len(parsed))
}
if parsed[0].Username != "alice" || parsed[0].TokenAmount != 100 || parsed[0].Experience != 50 {
t.Errorf("parsed[0] = %+v, unexpected values", parsed[0])
}
}
func TestRewardResult_JSONRoundTrip(t *testing.T) {
results := []RewardResult{
{Username: "alice", Success: true},
{Username: "bob", Success: false, Error: "insufficient balance"},
}
data, err := json.Marshal(results)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var parsed []RewardResult
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(parsed) != 2 {
t.Fatalf("got %d results, want 2", len(parsed))
}
if !parsed[0].Success {
t.Error("parsed[0].Success should be true")
}
if parsed[1].Success {
t.Error("parsed[1].Success should be false")
}
if parsed[1].Error != "insufficient balance" {
t.Errorf("parsed[1].Error = %q, want %q", parsed[1].Error, "insufficient balance")
}
}
func TestEntryTokenData_JSONRoundTrip(t *testing.T) {
data := entryTokenData{
Username: "player1",
SessionName: "Dedi1_Room_01",
}
b, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var parsed entryTokenData
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if parsed.Username != data.Username {
t.Errorf("Username = %q, want %q", parsed.Username, data.Username)
}
if parsed.SessionName != data.SessionName {
t.Errorf("SessionName = %q, want %q", parsed.SessionName, data.SessionName)
}
}
// ---------------------------------------------------------------------------
// Tests for Service constructor and callback setters
// ---------------------------------------------------------------------------
func TestNewService_NilParams(t *testing.T) {
svc := NewService(nil, nil)
if svc == nil {
t.Error("NewService should return non-nil service")
}
}
func TestSetRewardGranter(t *testing.T) {
svc := NewService(nil, nil)
svc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
return nil
})
if svc.rewardGrant == nil {
t.Error("rewardGrant should be set after SetRewardGranter")
}
}
func TestSetExpGranter(t *testing.T) {
svc := NewService(nil, nil)
svc.SetExpGranter(func(username string, exp int) error {
return nil
})
if svc.expGrant == nil {
t.Error("expGrant should be set after SetExpGranter")
}
}
func TestStaleTimeout_Value(t *testing.T) {
if staleTimeout != 30*time.Second {
t.Errorf("staleTimeout = %v, want 30s", staleTimeout)
}
}
func TestEntryTokenTTL_Value(t *testing.T) {
if entryTokenTTL != 5*time.Minute {
t.Errorf("entryTokenTTL = %v, want 5m", entryTokenTTL)
}
}
// ---------------------------------------------------------------------------
// Tests using mock repository for deeper logic testing
// ---------------------------------------------------------------------------
// mockRepo implements the methods needed by testableService to test
// business logic without a real database.
type mockRepo struct {
rooms map[string]*BossRoom
activeCounts map[string]int64
nextID uint
}
func newMockRepo() *mockRepo {
return &mockRepo{
rooms: make(map[string]*BossRoom),
activeCounts: make(map[string]int64),
nextID: 1,
}
}
// testableService mirrors the validation and state-transition logic of Service
// but uses an in-memory mock repository instead of GORM + MySQL.
// This lets us test business rules without external dependencies.
type testableService struct {
repo *mockRepo
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("empty players")
}
if len(usernames) > 3 {
return nil, fmt.Errorf("too many players")
}
seen := make(map[string]bool, len(usernames))
for _, u := range usernames {
if seen[u] {
return nil, fmt.Errorf("duplicate: %s", u)
}
seen[u] = true
}
for _, u := range usernames {
if s.repo.activeCounts[u] > 0 {
return nil, fmt.Errorf("player %s already active", u)
}
}
playersJSON, _ := json.Marshal(usernames)
sessionName := fmt.Sprintf("test_session_%d", s.repo.nextID)
room := &BossRoom{
ID: s.repo.nextID,
SessionName: sessionName,
BossID: bossID,
Status: StatusWaiting,
MaxPlayers: defaultMaxPlayers,
Players: string(playersJSON),
CreatedAt: time.Now(),
}
s.repo.nextID++
s.repo.rooms[sessionName] = room
return room, nil
}
func (s *testableService) completeRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
room, ok := s.repo.rooms[sessionName]
if !ok {
return nil, nil, fmt.Errorf("room not found")
}
if room.Status != StatusInProgress {
return nil, nil, fmt.Errorf("wrong status: %s", room.Status)
}
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return nil, nil, fmt.Errorf("parse players: %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 nil, nil, fmt.Errorf("%s is not a room member", r.Username)
}
}
now := time.Now()
room.Status = StatusCompleted
room.CompletedAt = &now
var results []RewardResult
if s.rewardGrant != nil {
for _, r := range rewards {
grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets)
res := RewardResult{Username: r.Username, Success: grantErr == nil}
if grantErr != nil {
res.Error = grantErr.Error()
}
results = append(results, res)
}
}
return room, results, nil
}
func (s *testableService) failRaid(sessionName string) (*BossRoom, error) {
room, ok := s.repo.rooms[sessionName]
if !ok {
return nil, fmt.Errorf("room not found")
}
if room.Status != StatusWaiting && room.Status != StatusInProgress {
return nil, fmt.Errorf("wrong status: %s", room.Status)
}
now := time.Now()
room.Status = StatusFailed
room.CompletedAt = &now
return room, nil
}
// ---------------------------------------------------------------------------
// Mock-based tests for business logic
// ---------------------------------------------------------------------------
func TestMock_RequestEntry_Success(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, err := svc.requestEntry([]string{"p1", "p2"}, 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 TestMock_RequestEntry_PlayerAlreadyActive(t *testing.T) {
repo := newMockRepo()
repo.activeCounts["p1"] = 1
svc := &testableService{repo: repo}
_, err := svc.requestEntry([]string{"p1", "p2"}, 1)
if err == nil {
t.Error("expected error for already-active player")
}
}
func TestMock_CompleteRaid_Success(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1", "p2"}, 1)
room.Status = StatusInProgress
completed, _, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 100},
})
if err != nil {
t.Fatalf("completeRaid failed: %v", err)
}
if completed.Status != StatusCompleted {
t.Errorf("Status = %q, want %q", completed.Status, StatusCompleted)
}
}
func TestMock_CompleteRaid_WrongStatus(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
// still in "waiting" status
_, _, err := svc.completeRaid(room.SessionName, nil)
if err == nil {
t.Error("expected error for wrong status")
}
}
func TestMock_CompleteRaid_InvalidRecipient(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
_, _, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "stranger", TokenAmount: 100},
})
if err == nil {
t.Error("expected error for non-member reward recipient")
}
}
func TestMock_CompleteRaid_WithRewardGranter(t *testing.T) {
grantCalls := 0
svc := &testableService{
repo: newMockRepo(),
rewardGrant: func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
grantCalls++
return nil
},
}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
_, results, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 50},
})
if err != nil {
t.Fatalf("completeRaid failed: %v", err)
}
if grantCalls != 1 {
t.Errorf("grant calls = %d, want 1", grantCalls)
}
if len(results) != 1 || !results[0].Success {
t.Errorf("expected 1 successful result, got %+v", results)
}
}
func TestMock_CompleteRaid_RewardFailure(t *testing.T) {
svc := &testableService{
repo: newMockRepo(),
rewardGrant: func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
return fmt.Errorf("chain error")
},
}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
completed, results, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 50},
})
if err != nil {
t.Fatalf("completeRaid should not fail when granter fails: %v", err)
}
if completed.Status != StatusCompleted {
t.Errorf("room should still be completed despite reward failure")
}
if len(results) != 1 || results[0].Success {
t.Error("expected failed reward result")
}
if results[0].Error == "" {
t.Error("expected error message in result")
}
}
func TestMock_FailRaid_FromWaiting(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
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("Status = %q, want %q", failed.Status, StatusFailed)
}
}
func TestMock_FailRaid_FromInProgress(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.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 TestMock_FailRaid_FromCompleted(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusCompleted
_, err := svc.failRaid(room.SessionName)
if err == nil {
t.Error("expected error failing completed raid")
}
}
func TestMock_FullLifecycle(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
// Create room
room, err := svc.requestEntry([]string{"p1", "p2"}, 1)
if err != nil {
t.Fatalf("requestEntry: %v", err)
}
if room.Status != StatusWaiting {
t.Fatalf("expected waiting, got %s", room.Status)
}
// Start raid
room.Status = StatusInProgress
// Complete raid
completed, _, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 10},
})
if err != nil {
t.Fatalf("completeRaid: %v", err)
}
if completed.Status != StatusCompleted {
t.Errorf("expected completed, got %s", completed.Status)
}
// Cannot fail a completed raid
_, err = svc.failRaid(room.SessionName)
if err == nil {
t.Error("expected error failing completed raid")
}
}