test: 인증·퀴즈 서비스 단위 테스트 추가 (오늘 난 버그 회귀 방지)

- AuthServiceTest 6개:
  · 비밀번호 변경이 실제로 저장되는지 (detached 엔티티 버그 회귀 테스트)
  · 현재 비번 틀리면 401·변경 안 됨
  · 회원가입 아이디/이메일 중복 409
  · 멘토 재설정은 학생 계정만(멘토 대상이면 403)
- QuizServiceTest 3개:
  · 안 푼 문제는 오답 처리·점수 집계
  · 답 null이어도 안 터짐
  · 퀴즈 없는 코스는 404
- PasswordEncoder는 진짜 BCrypt, 리포지토리만 mock (DB 없이 빠르게)
- User/QuizQuestion의 id는 ReflectionTestUtils로 주입(테스트 기법 예시)

전체 12개 테스트 통과 (기존 3 + 신규 9)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 09:18:37 +09:00
parent 5e8f96719b
commit 8332f306f7
2 changed files with 249 additions and 0 deletions

View File

@ -0,0 +1,149 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 파일이 하는 :
* AuthService의 핵심 규칙을 검증하는 단위 테스트다. 특히 실제로 났던 버그들의 재발을 막는다.
*
* 학습 포인트: 테스트는 "규칙이 있는 곳" 쓴다.
* 여기서 검증하는 규칙 (1) 비밀번호 변경은 정말 저장돼야 한다(오늘 버그!),
* (2) 현재 비밀번호를 틀리면 막는다, (3) 아이디·이메일 중복은 막는다,
* (4) 멘토 재설정은 학생 계정만 대상.
*
* 학습 포인트 : PasswordEncoder는 가짜(mock) 아니라 진짜 BCrypt를 쓴다.
* "비밀번호가 실제로 맞는지" 검증하려면 진짜 해시·비교가 필요하기 때문이다.
* 반대로 DB(리포지토리) mock으로 바꿔 DB 없이 빠르게 돈다.
*/
class AuthServiceTest {
private UserRepository userRepository;
private StudentProfileRepository studentProfileRepository;
private PasswordEncoder passwordEncoder;
private AuthService authService;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
studentProfileRepository = mock(StudentProfileRepository.class);
passwordEncoder = new BCryptPasswordEncoder(); // 진짜 인코더
authService = new AuthService(userRepository, studentProfileRepository, passwordEncoder);
}
/** 테스트용 사용자 하나 만들기. User엔 id setter가 없어 리플렉션으로 넣는다. */
private User userWith(long id, String rawPassword, String role) {
User user = new User("someone", passwordEncoder.encode(rawPassword), "이름", role, "DEV");
ReflectionTestUtils.setField(user, "id", id);
return user;
}
// 비밀번호 변경 (오늘 버그의 회귀 테스트)
@Test
@DisplayName("비밀번호 변경: 현재 비번이 맞으면 새 비번으로 '실제로' 바뀐다")
void changePassword_actually_updates() {
// 로그인 사용자(트랜잭션 밖에서 detached 객체를 흉내)
User loginUser = userWith(5L, "oldpass123", "MENTOR");
// 트랜잭션 안에서 다시 조회될 managed 사용자 객체가 바뀌어야 진짜 저장된
User managed = userWith(5L, "oldpass123", "MENTOR");
when(userRepository.findById(5L)).thenReturn(Optional.of(managed));
authService.changePassword(loginUser, "oldpass123", "newpass456");
// 핵심 검증 : 트랜잭션 안에서 재조회했는가? (detached 객체를 그냥 쓰면 저장이 됐던 버그)
verify(userRepository).findById(5L);
// 핵심 검증 : 재조회한(managed) 사용자의 비밀번호가 실제로 값으로 바뀌었는가?
assertTrue(passwordEncoder.matches("newpass456", managed.getPasswordHash()));
assertFalse(passwordEncoder.matches("oldpass123", managed.getPasswordHash()));
}
@Test
@DisplayName("비밀번호 변경: 현재 비번이 틀리면 401을 던지고 바꾸지 않는다")
void changePassword_rejects_wrong_current() {
User loginUser = userWith(5L, "oldpass123", "MENTOR");
User managed = userWith(5L, "oldpass123", "MENTOR");
when(userRepository.findById(5L)).thenReturn(Optional.of(managed));
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> authService.changePassword(loginUser, "틀린현재비번", "newpass456"));
assertEquals(401, ex.getStatusCode().value());
// 비밀번호는 그대로여야 한다
assertTrue(passwordEncoder.matches("oldpass123", managed.getPasswordHash()));
}
// 회원가입 중복 방어
@Test
@DisplayName("회원가입: 아이디가 이미 있으면 409를 던진다")
void signup_rejects_duplicate_username() {
when(userRepository.existsByUsername("honggildong")).thenReturn(true);
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> authService.signup(sampleSignup("honggildong", "hong")));
assertEquals(409, ex.getStatusCode().value());
verify(userRepository, never()).save(any());
}
@Test
@DisplayName("회원가입: 회사 이메일 아이디가 이미 있으면 409를 던진다")
void signup_rejects_duplicate_email() {
when(userRepository.existsByUsername("honggildong")).thenReturn(false);
when(studentProfileRepository.existsByCompanyEmailLocal("hong")).thenReturn(true);
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> authService.signup(sampleSignup("honggildong", "hong")));
assertEquals(409, ex.getStatusCode().value());
verify(userRepository, never()).save(any());
}
// 멘토 재설정 권한
@Test
@DisplayName("멘토 재설정: 대상이 멘토(학생 아님)이면 403을 던진다")
void mentorReset_rejects_non_student() {
User anotherMentor = userWith(7L, "whatever12", "MENTOR");
when(userRepository.findById(7L)).thenReturn(Optional.of(anotherMentor));
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> authService.resetPasswordByMentor(7L, "newpass456"));
assertEquals(403, ex.getStatusCode().value());
}
@Test
@DisplayName("멘토 재설정: 대상이 학생이면 새 비번으로 바뀐다")
void mentorReset_updates_student() {
User student = userWith(3L, "oldpass123", "STUDENT");
when(userRepository.findById(3L)).thenReturn(Optional.of(student));
authService.resetPasswordByMentor(3L, "newpass456");
assertTrue(passwordEncoder.matches("newpass456", student.getPasswordHash()));
}
/** 회원가입 요청 본문 샘플 — 테스트마다 다시 쓰지 않게 한 곳에 모은다. */
private dev.awesomedev.mirim.web.dto.SignupRequest sampleSignup(String username, String emailLocal) {
return new dev.awesomedev.mirim.web.dto.SignupRequest(
username, "password12", "홍길동", "DEV",
emailLocal, java.time.LocalDate.of(2007, 3, 15), "010-1234-5678",
"서울", "미림고", "소프트웨어과",
"홍판서", "", "010-9999-8888");
}
}

View File

@ -0,0 +1,100 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.QuizQuestion;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.QuizQuestionRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 파일이 하는 :
* QuizService의 채점 규칙을 검증한다. 특히 놓치기 쉬운 경계 규칙
* "안 푼 문제는 오답", "정답은 서버 기준으로 채점", "응시 기록을 남긴다".
*
* 학습 포인트: 채점처럼 "규칙이 곧 제품" 로직은 반드시 테스트로 박는다.
* 나중에 코드를 고치다 실수로 규칙이 바뀌면, 테스트가 빨간불로 알려 준다.
*/
class QuizServiceTest {
private QuizQuestionRepository quizQuestionRepository;
private QuizResultRepository quizResultRepository;
private QuizService quizService;
private User student;
@BeforeEach
void setUp() {
quizQuestionRepository = mock(QuizQuestionRepository.class);
quizResultRepository = mock(QuizResultRepository.class);
quizService = new QuizService(quizQuestionRepository, quizResultRepository);
student = new User("student1", "해시", "수습생1", "STUDENT", "DEV");
}
/** 정답 인덱스가 answerIdx인 문항 하나. id를 리플렉션으로 넣는다(테스트용). */
private QuizQuestion question(long id, int answerIdx) {
QuizQuestion q = new QuizQuestion("hardware", "질문?", "보기1", "보기2", "보기3", "보기4",
answerIdx, "해설", 0);
ReflectionTestUtils.setField(q, "id", id);
return q;
}
@Test
@DisplayName("맞은 개수만큼 점수가 나오고, 안 푼 문제는 오답으로 처리한다")
void grade_counts_correct_and_treats_unanswered_as_wrong() {
// 3문항: 정답이 각각 0, 1, 2
List<QuizQuestion> questions = List.of(question(1, 0), question(2, 1), question(3, 2));
when(quizQuestionRepository.findByCourseSlugOrderBySortOrderAsc("hardware"))
.thenReturn(questions);
// 학생 : 1번은 정답(0), 2번은 오답(3), 3번은 아예 (맵에 없음)
Map<Long, Integer> answers = Map.of(1L, 0, 2L, 3);
QuizService.GradeResult result = quizService.grade(student, "hardware", answers);
assertEquals(1, result.score()); // 맞은 1번 하나
assertEquals(3, result.total());
// 3번은 chosenIndex가 null이고 오답 처리
QuizService.GradedQuestion q3 = result.results().get(2);
assertNull(q3.chosenIndex());
assertFalse(q3.correct());
// 응시 기록을 남겼는지 (다시 풀기·성적 집계의 근거)
verify(quizResultRepository).save(any(QuizResult.class));
}
@Test
@DisplayName("답을 하나도 안 보내도(null) 터지지 않고 전부 오답으로 채점한다")
void grade_handles_null_answers() {
when(quizQuestionRepository.findByCourseSlugOrderBySortOrderAsc("hardware"))
.thenReturn(List.of(question(1, 0), question(2, 1)));
QuizService.GradeResult result = quizService.grade(student, "hardware", null);
assertEquals(0, result.score());
assertEquals(2, result.total());
}
@Test
@DisplayName("퀴즈가 없는 코스를 채점하려 하면 404를 던진다")
void grade_throws_404_when_no_quiz() {
when(quizQuestionRepository.findByCourseSlugOrderBySortOrderAsc("nope"))
.thenReturn(List.of());
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> quizService.grade(student, "nope", Map.of()));
assertEquals(404, ex.getStatusCode().value());
verify(quizResultRepository, never()).save(any());
}
}