- 체인 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>
310 lines
7.7 KiB
Go
310 lines
7.7 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 announcement
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock repository — implements the same methods that Service calls on *Repository.
|
|
// We embed it into a real *Repository via a wrapper approach.
|
|
// Since Service uses concrete *Repository, we create a repositoryInterface and
|
|
// a testableService that mirrors Service but uses the interface.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type repositoryInterface interface {
|
|
FindAll() ([]Announcement, error)
|
|
FindByID(id uint) (*Announcement, error)
|
|
Create(a *Announcement) error
|
|
Save(a *Announcement) error
|
|
Delete(id uint) error
|
|
}
|
|
|
|
type testableService struct {
|
|
repo repositoryInterface
|
|
}
|
|
|
|
func (s *testableService) GetAll() ([]Announcement, error) {
|
|
return s.repo.FindAll()
|
|
}
|
|
|
|
func (s *testableService) Create(title, content string) (*Announcement, error) {
|
|
a := &Announcement{Title: title, Content: content}
|
|
return a, s.repo.Create(a)
|
|
}
|
|
|
|
func (s *testableService) Update(id uint, title, content string) (*Announcement, error) {
|
|
a, err := s.repo.FindByID(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("공지사항을 찾을 수 없습니다")
|
|
}
|
|
if title != "" {
|
|
a.Title = title
|
|
}
|
|
if content != "" {
|
|
a.Content = content
|
|
}
|
|
return a, s.repo.Save(a)
|
|
}
|
|
|
|
func (s *testableService) Delete(id uint) error {
|
|
if _, err := s.repo.FindByID(id); err != nil {
|
|
return fmt.Errorf("공지사항을 찾을 수 없습니다")
|
|
}
|
|
return s.repo.Delete(id)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type mockRepo struct {
|
|
announcements map[uint]*Announcement
|
|
nextID uint
|
|
findAllErr error
|
|
createErr error
|
|
saveErr error
|
|
deleteErr error
|
|
}
|
|
|
|
func newMockRepo() *mockRepo {
|
|
return &mockRepo{
|
|
announcements: make(map[uint]*Announcement),
|
|
nextID: 1,
|
|
}
|
|
}
|
|
|
|
func (m *mockRepo) FindAll() ([]Announcement, error) {
|
|
if m.findAllErr != nil {
|
|
return nil, m.findAllErr
|
|
}
|
|
result := make([]Announcement, 0, len(m.announcements))
|
|
for _, a := range m.announcements {
|
|
result = append(result, *a)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockRepo) FindByID(id uint) (*Announcement, error) {
|
|
a, ok := m.announcements[id]
|
|
if !ok {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
// Return a copy so mutations don't affect the store until Save is called
|
|
cp := *a
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *mockRepo) Create(a *Announcement) error {
|
|
if m.createErr != nil {
|
|
return m.createErr
|
|
}
|
|
a.ID = m.nextID
|
|
a.CreatedAt = time.Now()
|
|
a.UpdatedAt = time.Now()
|
|
m.nextID++
|
|
stored := *a
|
|
m.announcements[a.ID] = &stored
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRepo) Save(a *Announcement) error {
|
|
if m.saveErr != nil {
|
|
return m.saveErr
|
|
}
|
|
a.UpdatedAt = time.Now()
|
|
stored := *a
|
|
m.announcements[a.ID] = &stored
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRepo) Delete(id uint) error {
|
|
if m.deleteErr != nil {
|
|
return m.deleteErr
|
|
}
|
|
delete(m.announcements, id)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetAll_ReturnsAnnouncements(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
// Empty at first
|
|
list, err := svc.GetAll()
|
|
if err != nil {
|
|
t.Fatalf("GetAll failed: %v", err)
|
|
}
|
|
if len(list) != 0 {
|
|
t.Errorf("expected 0 announcements, got %d", len(list))
|
|
}
|
|
|
|
// Add some
|
|
_, _ = svc.Create("Title 1", "Content 1")
|
|
_, _ = svc.Create("Title 2", "Content 2")
|
|
|
|
list, err = svc.GetAll()
|
|
if err != nil {
|
|
t.Fatalf("GetAll failed: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Errorf("expected 2 announcements, got %d", len(list))
|
|
}
|
|
}
|
|
|
|
func TestGetAll_ReturnsError(t *testing.T) {
|
|
repo := newMockRepo()
|
|
repo.findAllErr = fmt.Errorf("db connection error")
|
|
svc := &testableService{repo: repo}
|
|
|
|
_, err := svc.GetAll()
|
|
if err == nil {
|
|
t.Error("expected error from GetAll, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCreate_Success(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
a, err := svc.Create("Test Title", "Test Content")
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
if a.Title != "Test Title" {
|
|
t.Errorf("Title = %q, want %q", a.Title, "Test Title")
|
|
}
|
|
if a.Content != "Test Content" {
|
|
t.Errorf("Content = %q, want %q", a.Content, "Test Content")
|
|
}
|
|
if a.ID == 0 {
|
|
t.Error("expected non-zero ID after Create")
|
|
}
|
|
}
|
|
|
|
func TestCreate_EmptyTitle(t *testing.T) {
|
|
// The current service does not validate title presence — it delegates to the DB.
|
|
// This test documents that behavior: an empty title goes through to the repo.
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
a, err := svc.Create("", "Some content")
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
if a.Title != "" {
|
|
t.Errorf("Title = %q, want empty", a.Title)
|
|
}
|
|
}
|
|
|
|
func TestCreate_RepoError(t *testing.T) {
|
|
repo := newMockRepo()
|
|
repo.createErr = fmt.Errorf("insert failed")
|
|
svc := &testableService{repo: repo}
|
|
|
|
_, err := svc.Create("Title", "Content")
|
|
if err == nil {
|
|
t.Error("expected error when repo returns error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_Success(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
created, _ := svc.Create("Original Title", "Original Content")
|
|
|
|
updated, err := svc.Update(created.ID, "New Title", "New Content")
|
|
if err != nil {
|
|
t.Fatalf("Update failed: %v", err)
|
|
}
|
|
if updated.Title != "New Title" {
|
|
t.Errorf("Title = %q, want %q", updated.Title, "New Title")
|
|
}
|
|
if updated.Content != "New Content" {
|
|
t.Errorf("Content = %q, want %q", updated.Content, "New Content")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_PartialUpdate(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
created, _ := svc.Create("Original Title", "Original Content")
|
|
|
|
// Update only title (empty content means keep existing)
|
|
updated, err := svc.Update(created.ID, "New Title", "")
|
|
if err != nil {
|
|
t.Fatalf("Update failed: %v", err)
|
|
}
|
|
if updated.Title != "New Title" {
|
|
t.Errorf("Title = %q, want %q", updated.Title, "New Title")
|
|
}
|
|
if updated.Content != "Original Content" {
|
|
t.Errorf("Content = %q, want %q (should be unchanged)", updated.Content, "Original Content")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_NotFound(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
_, err := svc.Update(999, "Title", "Content")
|
|
if err == nil {
|
|
t.Error("expected error updating non-existent announcement, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDelete_Success(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
created, _ := svc.Create("To Delete", "Content")
|
|
|
|
err := svc.Delete(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("Delete failed: %v", err)
|
|
}
|
|
|
|
// Verify it's gone
|
|
list, _ := svc.GetAll()
|
|
if len(list) != 0 {
|
|
t.Errorf("expected 0 announcements after delete, got %d", len(list))
|
|
}
|
|
}
|
|
|
|
func TestDelete_NotFound(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
err := svc.Delete(999)
|
|
if err == nil {
|
|
t.Error("expected error deleting non-existent announcement, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDelete_RepoError(t *testing.T) {
|
|
repo := newMockRepo()
|
|
svc := &testableService{repo: repo}
|
|
|
|
created, _ := svc.Create("Title", "Content")
|
|
|
|
repo.deleteErr = fmt.Errorf("delete failed")
|
|
err := svc.Delete(created.ID)
|
|
if err == nil {
|
|
t.Error("expected error when repo delete fails, got nil")
|
|
}
|
|
}
|