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:
parent
5e8f96719b
commit
8332f306f7
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user