Files
a301_server/internal/announcement/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

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")
}
}