From fdde78d47925cc07adfe82a02c0a3fc0911a59b4 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 12 Oct 2025 03:29:03 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20auth=20=EA=B4=80=EB=A0=A8=20m?= =?UTF-8?q?apper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InsightPrep/InsightPrepApplication.java | 2 ++ .../auth/repository/AuthRepository.java | 25 +++++++++++++++++++ .../domain/auth/service/AuthServiceImpl.java | 7 ++++-- .../domain/auth/service/EmailServiceImpl.java | 4 ++- .../service/PasswordResetServiceImpl.java | 8 +++--- .../service/CustomUserDetailsService.java | 4 ++- .../global/auth/util/SecurityUtil.java | 4 ++- .../service/AuthServiceImplSignUpTest.java | 12 ++++++--- .../auth/service/AuthServiceImplTest.java | 10 +++++--- .../auth/service/EmailServiceImplTest.java | 6 ++++- .../service/PasswordResetServiceImplTest.java | 25 +++++++++++-------- .../service/CustomUserDetailsServiceTest.java | 8 ++++-- .../global/auth/util/SecurityUtilTest.java | 8 ++++-- 13 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/auth/repository/AuthRepository.java diff --git a/src/main/java/com/project/InsightPrep/InsightPrepApplication.java b/src/main/java/com/project/InsightPrep/InsightPrepApplication.java index 371f8d4..cce4bce 100644 --- a/src/main/java/com/project/InsightPrep/InsightPrepApplication.java +++ b/src/main/java/com/project/InsightPrep/InsightPrepApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAsync @EnableScheduling +@EnableJpaAuditing public class InsightPrepApplication { public static void main(String[] args) { diff --git a/src/main/java/com/project/InsightPrep/domain/auth/repository/AuthRepository.java b/src/main/java/com/project/InsightPrep/domain/auth/repository/AuthRepository.java new file mode 100644 index 0000000..bee0fe1 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/repository/AuthRepository.java @@ -0,0 +1,25 @@ +package com.project.InsightPrep.domain.auth.repository; + +import com.project.InsightPrep.domain.member.entity.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AuthRepository extends JpaRepository { + + // 이메일 중복 여부 확인 + boolean existsByEmail(String email); + + // 이메일로 회원 조회 + Optional findByEmail(String email); + + // ID로 회원 조회 + Optional findById(Long id); + + // 이메일로 비밀번호 변경 (Update 쿼리) + @Modifying(clearAutomatically = true) + @Query("UPDATE Member m SET m.password = :passwordHash WHERE m.email = :email") + int updatePasswordByEmail(@Param("email") String email, @Param("passwordHash") String passwordHash); +} diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java index caa91f7..de3375e 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java @@ -7,6 +7,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; @@ -32,6 +33,7 @@ public class AuthServiceImpl implements AuthService { private final PasswordEncoder passwordEncoder; private final AuthMapper authMapper; + private final AuthRepository authRepository; private final EmailService emailService; private final SecurityUtil securityUtil; @@ -47,13 +49,14 @@ public void signup(signupDto dto) { .role(Role.USER) .build(); - authMapper.insertMember(member); + //authMapper.insertMember(member); + authRepository.save(member); } @Override @Transactional public LoginResultDto login(LoginDto dto) { - Member member = authMapper.findByEmail(dto.getEmail()).orElseThrow(() -> new AuthException(AuthErrorCode.LOGIN_FAIL)); + Member member = authRepository.findByEmail(dto.getEmail()).orElseThrow(() -> new AuthException(AuthErrorCode.LOGIN_FAIL)); if (!passwordEncoder.matches(dto.getPassword(), member.getPassword())) { throw new AuthException(AuthErrorCode.LOGIN_FAIL); diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java index 5ce4565..31c498a 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java @@ -5,6 +5,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.EmailMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import java.time.LocalDateTime; @@ -26,6 +27,7 @@ public class EmailServiceImpl implements EmailService { private final JavaMailSender emailSender; private final AuthMapper authMapper; + private final AuthRepository authRepository; private final EmailMapper emailMapper; private static final long EXPIRE_MINUTES = 10; @@ -104,7 +106,7 @@ private void currentEmailExisting(String email) { @Override public void existEmail(String email) { - if (authMapper.existEmail(email)) { + if (authRepository.existsByEmail(email)) { throw new AuthException(AuthErrorCode.EMAIL_DUPLICATE_ERROR); } } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java index c0dd308..96f90e2 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java @@ -5,6 +5,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.mail.MessagingException; @@ -28,12 +29,13 @@ public class PasswordResetServiceImpl implements PasswordResetService { private final EmailService emailService; private final PasswordMapper passwordMapper; private final AuthMapper authMapper; + private final AuthRepository authRepository; private final SecurityUtil securityUtil; @Override @Transactional public void requestOtp(String email) { - boolean exists = authMapper.existEmail(email); + boolean exists = authRepository.existsByEmail(email); // exists=false여도 "요청 접수" 응답은 동일하게. (계정 존재 여부 노출 방지) if (!exists) { // 계정이 없으면 그냥 "요청 접수"처럼 리턴 (메일 안보냄) @@ -135,11 +137,11 @@ public void resetPassword(String resetToken, String newRawPassword) { // 3) 멤버 조회(존재 확인) String email = row.getEmail(); - Member member = authMapper.findByEmail(email).orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + Member member = authRepository.findByEmail(email).orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); // 4) 패스워드 해시 & 저장 String hashed = securityUtil.encode(newRawPassword); - int updated = authMapper.updatePasswordByEmail(email, hashed); + int updated = authRepository.updatePasswordByEmail(email, hashed); if (updated != 1) { throw new AuthException(AuthErrorCode.SERVER_ERROR); } diff --git a/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java b/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java index ad0893d..6dfe63b 100644 --- a/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java +++ b/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java @@ -1,6 +1,7 @@ package com.project.InsightPrep.global.auth.service; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; import lombok.RequiredArgsConstructor; @@ -14,10 +15,11 @@ public class CustomUserDetailsService implements UserDetailsService { private final AuthMapper authMapper; + private final AuthRepository authRepository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - Member member = authMapper.findByEmail(email) + Member member = authRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 사용자입니다.")); return new CustomUserDetails(member); } diff --git a/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java b/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java index 066c8f2..e86da84 100644 --- a/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java +++ b/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java @@ -3,6 +3,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; import com.project.InsightPrep.domain.member.entity.Member; import lombok.RequiredArgsConstructor; @@ -16,6 +17,7 @@ public class SecurityUtil { private final AuthMapper authMapper; + private final AuthRepository authRepository; private final PasswordEncoder passwordEncoder; public Long getLoginMemberId() { @@ -36,7 +38,7 @@ public Long getLoginMemberId() { public Member getAuthenticatedMember() { Long memberId = getLoginMemberId(); - return authMapper.findById(memberId) + return authRepository.findById(memberId) .orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); } diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java index a9a75d4..65849a2 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java @@ -11,6 +11,7 @@ import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; import jakarta.servlet.http.HttpServletRequest; @@ -38,6 +39,9 @@ public class AuthServiceImplSignUpTest { @Mock private AuthMapper authMapper; + @Mock + private AuthRepository authRepository; + @Mock private PasswordEncoder passwordEncoder; @@ -64,7 +68,7 @@ void login_success() { LoginDto dto = new LoginDto("test@example.com", "Password123!", false); Member member = new Member(1L, "test@example.com", "Password123!","nickname", Role.USER, 10); - when(authMapper.findByEmail(dto.getEmail())).thenReturn(Optional.of(member)); + when(authRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(member)); when(passwordEncoder.matches(dto.getPassword(), member.getPassword())).thenReturn(true); // when @@ -81,7 +85,7 @@ void login_success() { void login_email_not_exist() { // given LoginDto dto = new LoginDto("test@example.com", "Password123!", false); - when(authMapper.findByEmail(dto.getEmail())).thenReturn(Optional.empty()); + when(authRepository.findByEmail(dto.getEmail())).thenReturn(Optional.empty()); // expect assertThrows(AuthException.class, () -> authService.login(dto)); @@ -94,7 +98,7 @@ void login_password_mismatch() { LoginDto dto = new LoginDto("test@example.com", "Password123", false); Member member = new Member(1L, "test@example.com", "Password123!","nickname", Role.USER, 10); - when(authMapper.findByEmail(dto.getEmail())).thenReturn(Optional.of(member)); + when(authRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(member)); when(passwordEncoder.matches(dto.getPassword(), member.getPassword())).thenReturn(false); // expect @@ -108,7 +112,7 @@ void login_auto() { LoginDto dto = new LoginDto("test@example.com", "Password123!", true); Member member = new Member(1L, "test@example.com", "Password123!","nickname", Role.USER, 10); - when(authMapper.findByEmail(dto.getEmail())).thenReturn(Optional.of(member)); + when(authRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(member)); when(passwordEncoder.matches(dto.getPassword(), member.getPassword())).thenReturn(true); // when diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java index 2e40c74..a57e78c 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java @@ -12,6 +12,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -33,6 +34,9 @@ class AuthServiceImplTest { @Mock private AuthMapper authMapper; + @Mock + private AuthRepository authRepository; + @Mock private EmailService emailService; @@ -55,7 +59,7 @@ void signupSuccess() { // then verify(emailService).existEmail(dto.getEmail()); verify(emailService).validateEmailVerified(dto.getEmail()); - verify(authMapper).insertMember(any(Member.class)); + verify(authRepository).save(any(Member.class)); } @Test @@ -75,7 +79,7 @@ void passwordUnMatch() { // when & then assertThrows(AuthException.class, () -> authService.signup(dto)); - verify(authMapper, never()).insertMember(any()); + verify(authRepository, never()).save(any()); } @Test @@ -97,7 +101,7 @@ void passwordUnMatch() { // when & then assertThrows(AuthException.class, () -> authService.signup(dto)); - verify(authMapper, never()).insertMember(any()); + verify(authRepository, never()).save(any()); } } \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java index 90ea30b..4cf0400 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java @@ -16,6 +16,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.EmailMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import java.lang.reflect.Field; @@ -41,6 +42,9 @@ class EmailServiceImplTest { @Mock private AuthMapper authMapper; + @Mock + private AuthRepository authRepository; + @Mock private EmailMapper emailMapper; @@ -98,7 +102,7 @@ void createEmailForm_shouldReturnCorrectSimpleMailMessage() { @DisplayName("이메일 중복 - 중복일 경우 예외 발생") void existEmail_Duplicate_ThrowsException() { String email = "test@example.com"; - given(authMapper.existEmail(email)).willReturn(true); + given(authRepository.existsByEmail(email)).willReturn(true); assertThrows(AuthException.class, () -> emailService.existEmail(email)); } diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java index 6981352..c336d59 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java @@ -4,6 +4,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.mail.MessagingException; @@ -30,6 +31,8 @@ class PasswordResetServiceImplTest { @Mock PasswordMapper passwordMapper; @Mock AuthMapper authMapper; @Mock + AuthRepository authRepository; + @Mock SecurityUtil securityUtil; @InjectMocks PasswordResetServiceImpl service; @@ -44,7 +47,7 @@ void setUp() { @Test @DisplayName("requestOtp: 가입된 이메일이면 OTP 저장 및 메일 발송") void requestOtp_sendsMail_whenEmailExists() throws Exception { - when(authMapper.existEmail(EMAIL)).thenReturn(true); + when(authRepository.existsByEmail(EMAIL)).thenReturn(true); // execute service.requestOtp(EMAIL); @@ -65,7 +68,7 @@ void requestOtp_sendsMail_whenEmailExists() throws Exception { @Test @DisplayName("requestOtp: 미가입 이메일이면 아무 동작 없이 정상 종료 (정보 유출 방지)") void requestOtp_returnsSilently_whenEmailNotExist() throws Exception { - when(authMapper.existEmail(EMAIL)).thenReturn(false); + when(authRepository.existsByEmail(EMAIL)).thenReturn(false); service.requestOtp(EMAIL); @@ -76,7 +79,7 @@ void requestOtp_returnsSilently_whenEmailNotExist() throws Exception { @Test @DisplayName("requestOtp: 메일 전송 실패 시 RuntimeException 전파") void requestOtp_throws_whenSendMailFails() throws Exception { - when(authMapper.existEmail(EMAIL)).thenReturn(true); + when(authRepository.existsByEmail(EMAIL)).thenReturn(true); doThrow(new MessagingException("smtp down")) .when(emailService).sendEmail(anyString(), anyString(), anyString()); @@ -176,14 +179,14 @@ void resetPassword_success() { row.builder().email(EMAIL).build(); when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); when(securityUtil.encode("newP@ss!")).thenReturn("hashed"); - when(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); + when(authRepository.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); when(passwordMapper.markResetTokenUsed(token)).thenReturn(1); service.resetPassword(token, "newP@ss!"); - verify(authMapper).updatePasswordByEmail(EMAIL, "hashed"); + verify(authRepository).updatePasswordByEmail(EMAIL, "hashed"); verify(passwordMapper).markResetTokenUsed(token); } @@ -227,7 +230,7 @@ void resetPassword_memberNotFound() { row.builder().email(EMAIL).build(); when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.empty()); + when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.resetPassword(token, "pw")) .isInstanceOf(AuthException.class); @@ -242,9 +245,9 @@ void resetPassword_updatePasswordFail() { row.builder().email(EMAIL).build(); when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); when(securityUtil.encode("pw")).thenReturn("hashed"); - when(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(0); + when(authRepository.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(0); assertThatThrownBy(() -> service.resetPassword(token, "pw")) .isInstanceOf(AuthException.class); @@ -259,9 +262,9 @@ void resetPassword_markTokenFail() { row.builder().email(EMAIL).build(); when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); when(securityUtil.encode("pw")).thenReturn("hashed"); - when(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); + when(authRepository.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); when(passwordMapper.markResetTokenUsed(token)).thenReturn(0); assertThatThrownBy(() -> service.resetPassword(token, "pw")) diff --git a/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java b/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java index 5b77fbf..286cbae 100644 --- a/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java +++ b/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.given; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; import java.util.Optional; @@ -22,6 +23,9 @@ class CustomUserDetailsServiceTest { @Mock private AuthMapper authMapper; + @Mock + private AuthRepository authRepository; + @InjectMocks private CustomUserDetailsService customUserDetailsService; @@ -38,7 +42,7 @@ void loadUserByUsername_success() { .role(Role.USER) .build(); - given(authMapper.findByEmail(email)).willReturn(Optional.of(member)); + given(authRepository.findByEmail(email)).willReturn(Optional.of(member)); // when UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); @@ -56,7 +60,7 @@ void loadUserByUsername_success() { void loadUserByUsername_fail_userNotFound() { // given String email = "nonexistent@example.com"; - given(authMapper.findByEmail(email)).willReturn(Optional.empty()); + given(authRepository.findByEmail(email)).willReturn(Optional.empty()); // when & then assertThrows(UsernameNotFoundException.class, () -> { diff --git a/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java b/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java index 22e7b94..f77f557 100644 --- a/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java +++ b/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java @@ -6,6 +6,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; @@ -31,6 +32,9 @@ class SecurityUtilTest { @Mock private AuthMapper authMapper; + @Mock + private AuthRepository authRepository; + @AfterEach void clearContext() { SecurityContextHolder.clearContext(); @@ -104,7 +108,7 @@ void getAuthenticatedMember_success() { Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); - given(authMapper.findById(1L)).willReturn(Optional.of(member)); + given(authRepository.findById(1L)).willReturn(Optional.of(member)); // when Member result = securityUtil.getAuthenticatedMember(); @@ -129,7 +133,7 @@ void getAuthenticatedMember_fail_memberNotFound() { Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); - given(authMapper.findById(1L)).willReturn(Optional.empty()); + given(authRepository.findById(1L)).willReturn(Optional.empty()); // when & then AuthException exception = assertThrows(AuthException.class, () -> { From 77799eba57cc843869b8c70d4b14208f00d0bb23 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 12 Oct 2025 16:33:00 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20WebMvcTest=EB=82=98=20ControllerTe?= =?UTF-8?q?st=EB=8A=94=20DB=20=EA=B4=80=EB=A0=A8=20=EB=B9=88=EC=9D=84=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B8=B0=20?= =?UTF-8?q?=EB=95=8C=EB=AC=B8=EC=97=90Auditing=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9D=B4=20=ED=99=9C=EC=84=B1=ED=99=94=EB=90=98?= =?UTF-8?q?=EB=A9=B4=20=EC=B6=A9=EB=8F=8C=ED=95=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/InsightPrep/InsightPrepApplication.java | 1 - .../com/project/InsightPrep/global/config/JpaConfig.java | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/project/InsightPrep/global/config/JpaConfig.java diff --git a/src/main/java/com/project/InsightPrep/InsightPrepApplication.java b/src/main/java/com/project/InsightPrep/InsightPrepApplication.java index cce4bce..3d52846 100644 --- a/src/main/java/com/project/InsightPrep/InsightPrepApplication.java +++ b/src/main/java/com/project/InsightPrep/InsightPrepApplication.java @@ -9,7 +9,6 @@ @SpringBootApplication @EnableAsync @EnableScheduling -@EnableJpaAuditing public class InsightPrepApplication { public static void main(String[] args) { diff --git a/src/main/java/com/project/InsightPrep/global/config/JpaConfig.java b/src/main/java/com/project/InsightPrep/global/config/JpaConfig.java new file mode 100644 index 0000000..52e3647 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/global/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.project.InsightPrep.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} From 30a79841764609df361ba0fea250dce3eb69bca9 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 12 Oct 2025 17:27:51 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20email=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20#3?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/repository/EmailRepository.java | 29 +++++++++++++++ .../domain/auth/service/EmailServiceImpl.java | 37 ++++++++++--------- .../auth/service/EmailServiceImplTest.java | 24 +++++++----- 3 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/auth/repository/EmailRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/auth/repository/EmailRepository.java b/src/main/java/com/project/InsightPrep/domain/auth/repository/EmailRepository.java new file mode 100644 index 0000000..af40282 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/repository/EmailRepository.java @@ -0,0 +1,29 @@ +package com.project.InsightPrep.domain.auth.repository; + +import com.project.InsightPrep.domain.auth.entity.EmailVerification; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface EmailRepository extends JpaRepository { + + // 이메일과 코드로 조회 + Optional findByEmailAndCode(String email, String code); + + //이메일로 조회 + Optional findByEmail(String email); + + // 이메일로 삭제 + void deleteByEmail(String email); + + // 만료 시간 이전 데이터 삭제 + void deleteByExpiresTimeBefore(LocalDateTime expiresTime); + + // 인증 성공 시 verified = true 로 변경. Dirty Checking보다는 단일 업데이트 쿼리로 처리 + @Modifying(clearAutomatically = true) + @Query("UPDATE EmailVerification e SET e.verified = true WHERE e.email = :email AND e.code = :code") + int updateVerified(@Param("email") String email, @Param("code") String code); +} diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java index 31c498a..7b111ce 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java @@ -6,6 +6,7 @@ import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.EmailMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; +import com.project.InsightPrep.domain.auth.repository.EmailRepository; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import java.time.LocalDateTime; @@ -29,6 +30,7 @@ public class EmailServiceImpl implements EmailService { private final AuthMapper authMapper; private final AuthRepository authRepository; private final EmailMapper emailMapper; + private final EmailRepository emailRepository; private static final long EXPIRE_MINUTES = 10; @@ -94,14 +96,16 @@ public void sendCodeToEmail(String email) { } private void currentEmailExisting(String email) { - EmailVerification currentExisting = emailMapper.findByEmail(email); - if (currentExisting == null) return; - - if (currentExisting.getExpiresTime() == null || currentExisting.getExpiresTime().isBefore(LocalDateTime.now())) { - emailMapper.deleteByEmail(email); - } else { - throw new AuthException(AuthErrorCode.ALREADY_SEND_CODE_ERROR); - } + emailRepository.findByEmail(email) + .ifPresent(currentExisting -> { + // 만료되었거나 expiresTime이 null이면 삭제 + if (currentExisting.getExpiresTime() == null || currentExisting.getExpiresTime().isBefore(LocalDateTime.now())) { + emailRepository.deleteByEmail(email); + } else { + // 유효 기간이 남아있으면 예외 발생 + throw new AuthException(AuthErrorCode.ALREADY_SEND_CODE_ERROR); + } + }); } @Override @@ -118,7 +122,7 @@ private EmailVerification createVerificationCode(String email) { .code(randomCode) .expiresTime(LocalDateTime.now().plusMinutes(EXPIRE_MINUTES)) // 10분 후 만료 .build(); - emailMapper.insertCode(code); + emailRepository.save(code); return code; } @@ -134,14 +138,15 @@ private String generateRandomCode(int length) { } @Override + @Transactional public boolean verifyCode(String email, String code) { - return emailMapper.findByEmailAndCode(email, code) + return emailRepository.findByEmailAndCode(email, code) .map(vc -> { if (vc.getExpiresTime().isBefore(LocalDateTime.now())) { throw new AuthException(AuthErrorCode.EXPIRED_CODE_ERROR); // 만료된 코드 } - emailMapper.updateVerified(email, code); // 인증 완료 처리 + emailRepository.updateVerified(email, code); // 인증 완료 처리 return true; }) .orElseThrow(() -> new AuthException(AuthErrorCode.CODE_NOT_MATCH_ERROR)); @@ -151,15 +156,13 @@ public boolean verifyCode(String email, String code) { @Scheduled(cron = "0 0 12 * * ?") @Override public void deleteExpiredVerificationCodes() { - emailMapper.deleteByExpiresTimeBefore(LocalDateTime.now()); + emailRepository.deleteByExpiresTimeBefore(LocalDateTime.now()); } @Override public void validateEmailVerified(String email) { - EmailVerification verification = emailMapper.findByEmail(email); - - if (verification == null || !verification.isVerified()) { - throw new AuthException(AuthErrorCode.EMAIL_VERIFICATION_ERROR); - } + emailRepository.findByEmail(email) + .filter(EmailVerification::isVerified) // verified == true 인 경우만 통과 + .orElseThrow(() -> new AuthException(AuthErrorCode.EMAIL_VERIFICATION_ERROR)); } } diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java index 4cf0400..efa8a0e 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java @@ -17,6 +17,7 @@ import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.EmailMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; +import com.project.InsightPrep.domain.auth.repository.EmailRepository; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import java.lang.reflect.Field; @@ -48,6 +49,9 @@ class EmailServiceImplTest { @Mock private EmailMapper emailMapper; + @Mock + private EmailRepository emailRepository; + @Mock private MimeMessage mimeMessage; @@ -116,7 +120,7 @@ void currentEmailExisting_ExpiredCode_Deletes() throws Exception { .email(email) .expiresTime(LocalDateTime.now().minusMinutes(1)) .build(); - given(emailMapper.findByEmail(email)).willReturn(expired); + given(emailRepository.findByEmail(email)).willReturn(Optional.of(expired)); // 필요 mock 설정 MimeMessage mimeMessage = mock(MimeMessage.class); @@ -132,7 +136,7 @@ void currentEmailExisting_ExpiredCode_Deletes() throws Exception { emailService.sendCodeToEmail(email); // then - verify(emailMapper).deleteByEmail(email); + verify(emailRepository).deleteByEmail(email); } @Test @@ -143,7 +147,7 @@ void currentEmailExisting_NotExpired_ThrowsException() { .email(email) .expiresTime(LocalDateTime.now().plusMinutes(5)) .build(); - given(emailMapper.findByEmail(email)).willReturn(notExpired); + given(emailRepository.findByEmail(email)).willReturn(Optional.of(notExpired)); assertThrows(AuthException.class, () -> emailService.sendCodeToEmail(email)); } @@ -158,12 +162,12 @@ void verifyCode_Valid_Success() { .code(code) .expiresTime(LocalDateTime.now().plusMinutes(1)) .build(); - given(emailMapper.findByEmailAndCode(email, code)).willReturn(Optional.of(verification)); + given(emailRepository.findByEmailAndCode(email, code)).willReturn(Optional.of(verification)); boolean result = emailService.verifyCode(email, code); assertTrue(result); - verify(emailMapper).updateVerified(email, code); + verify(emailRepository).updateVerified(email, code); } @Test @@ -176,7 +180,7 @@ void verifyCode_Expired_ThrowsException() { .code(code) .expiresTime(LocalDateTime.now().minusMinutes(1)) .build(); - given(emailMapper.findByEmailAndCode(email, code)).willReturn(Optional.of(expired)); + given(emailRepository.findByEmailAndCode(email, code)).willReturn(Optional.of(expired)); assertThrows(AuthException.class, () -> emailService.verifyCode(email, code)); } @@ -184,7 +188,7 @@ void verifyCode_Expired_ThrowsException() { @Test @DisplayName("인증 코드 불일치 - 예외 발생") void verifyCode_NotFound_ThrowsException() { - given(emailMapper.findByEmailAndCode(anyString(), anyString())).willReturn(Optional.empty()); + given(emailRepository.findByEmailAndCode(anyString(), anyString())).willReturn(Optional.empty()); assertThrows(AuthException.class, () -> emailService.verifyCode("test@example.com", "ABC123")); } @@ -192,7 +196,7 @@ void verifyCode_NotFound_ThrowsException() { @Test @DisplayName("인증된 이메일 검증 - 실패 시 예외 발생") void validateEmailVerified_Invalid_ThrowsException() { - given(emailMapper.findByEmail(anyString())).willReturn(null); + given(emailRepository.findByEmail(anyString())).willReturn(Optional.empty()); assertThrows(AuthException.class, () -> emailService.validateEmailVerified("test@example.com")); } @@ -204,7 +208,7 @@ void deleteExpiredVerificationCodes_shouldCallEmailMapperOnce() { emailService.deleteExpiredVerificationCodes(); // then - verify(emailMapper, times(1)).deleteByExpiresTimeBefore(any(LocalDateTime.class)); + verify(emailRepository, times(1)).deleteByExpiresTimeBefore(any(LocalDateTime.class)); } @Test @@ -214,7 +218,7 @@ void validateEmailVerified_shouldThrowException_whenNotVerified() { String email = "user@test.com"; EmailVerification notVerified = new EmailVerification(1L, "test@example.com", "XWL9WS", false, LocalDateTime.now().plusMinutes(5)); - when(emailMapper.findByEmail(email)).thenReturn(notVerified); + when(emailRepository.findByEmail(email)).thenReturn(Optional.of(notVerified)); // when & then AuthException exception = assertThrows(AuthException.class, () -> { From 1e73450981c25774d9fddd863969d8291bce182d Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 12 Oct 2025 23:04:08 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20password=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/entity/PasswordVerification.java | 73 +++++++++ .../domain/auth/mapper/PasswordMapper.java | 4 +- .../auth/repository/PasswordRepository.java | 14 ++ .../service/PasswordResetServiceImpl.java | 82 ++++------ .../domain/member/entity/Member.java | 3 + .../service/PasswordResetServiceImplTest.java | 148 +++++++++--------- 6 files changed, 192 insertions(+), 132 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/auth/repository/PasswordRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/auth/entity/PasswordVerification.java b/src/main/java/com/project/InsightPrep/domain/auth/entity/PasswordVerification.java index 70ecec2..8576268 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/entity/PasswordVerification.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/entity/PasswordVerification.java @@ -1,5 +1,8 @@ package com.project.InsightPrep.domain.auth.entity; +import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; +import com.project.InsightPrep.domain.auth.exception.AuthException; +import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -58,4 +61,74 @@ public class PasswordVerification { @Column(name = "reset_used", nullable = false) private boolean resetUsed; + + public PasswordVerification updateOtp(String codeHash, int attemptsLeft, boolean used, LocalDateTime expiresAt) { + this.codeHash = codeHash; + this.attemptsLeft = attemptsLeft; + this.used = used; + this.expiresAt = expiresAt; + this.createdAt = LocalDateTime.now(); + return this; + } + + public static PasswordVerification createNew(String email, String codeHash, int attemptsLeft, boolean used, LocalDateTime expiresAt) { + return PasswordVerification.builder() + .email(email) + .codeHash(codeHash) + .attemptsLeft(attemptsLeft) + .used(used) + .expiresAt(expiresAt) + .createdAt(LocalDateTime.now()) + .resetUsed(false) + .build(); + } + + /** OTP 유효성 검증 */ + public void validateOtp(SecurityUtil securityUtil, String inputCode) { + if (this.used) { + throw new AuthException(AuthErrorCode.OTP_ALREADY_USED); + } + + if (this.expiresAt.isBefore(LocalDateTime.now())) { + throw new AuthException(AuthErrorCode.EXPIRED_CODE_ERROR); + } + + boolean matched = securityUtil.matches(inputCode, this.codeHash); + if (!matched) { + this.decreaseAttempts(); + if (this.attemptsLeft <= 0) { + this.markOtpUsed(); + throw new AuthException(AuthErrorCode.OTP_INVALID); + } + throw new AuthException(AuthErrorCode.OTP_INVALID_ATTEMPT); + } + } + + /** OTP 시도 횟수를 감소시킵니다. */ + public void decreaseAttempts() { + if (this.attemptsLeft > 0) { + this.attemptsLeft -= 1; + } + } + + /** OTP를 사용 처리합니다. */ + public void markOtpUsed() { + this.used = true; + this.usedAt = LocalDateTime.now(); + } + + /** 새로운 비밀번호 재설정 토큰을 발급합니다. */ + public void issueResetToken(String token, LocalDateTime expiresAt) { + this.resetToken = token; + this.resetUsed = false; + this.resetExpiresAt = expiresAt; + } + + /** 재설정 토큰을 사용 처리합니다. */ + public void markResetTokenUsed() { + this.resetUsed = true; + this.resetToken = null; + this.resetExpiresAt = null; + this.usedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java b/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java index 17a80b4..a2d8e3b 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java @@ -15,7 +15,7 @@ void upsertPasswordOtp(@Param("email") String email, @Param("expiresAt") LocalDateTime expiresAt, @Param("createdAt") LocalDateTime createdAt); - PasswordVerification findByEmail(@Param("email") String email); + PasswordVerification findByEmail(@Param("email") String email); // int updateAttempts(@Param("email") String email, @Param("attemptsLeft") int attemptsLeft); @@ -27,7 +27,7 @@ int updateResetToken(@Param("email") String email, @Param("resetUsed") boolean resetUsed, @Param("resetExpiresAt") LocalDateTime resetExpiresAt); - PasswordVerification findByResetToken(@Param("resetToken") String resetToken); + PasswordVerification findByResetToken(@Param("resetToken") String resetToken); // int markResetTokenUsed(@Param("resetToken") String resetToken); } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/repository/PasswordRepository.java b/src/main/java/com/project/InsightPrep/domain/auth/repository/PasswordRepository.java new file mode 100644 index 0000000..7262c18 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/repository/PasswordRepository.java @@ -0,0 +1,14 @@ +package com.project.InsightPrep.domain.auth.repository; + +import com.project.InsightPrep.domain.auth.entity.PasswordVerification; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PasswordRepository extends JpaRepository { + + Optional findByEmail(String email); + + Optional findByResetToken(String resetToken); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java index 96f90e2..4849250 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java @@ -6,6 +6,7 @@ import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; +import com.project.InsightPrep.domain.auth.repository.PasswordRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.mail.MessagingException; @@ -28,6 +29,7 @@ public class PasswordResetServiceImpl implements PasswordResetService { private final EmailService emailService; private final PasswordMapper passwordMapper; + private final PasswordRepository passwordRepository; private final AuthMapper authMapper; private final AuthRepository authRepository; private final SecurityUtil securityUtil; @@ -47,7 +49,12 @@ public void requestOtp(String email) { LocalDateTime now = LocalDateTime.now(); LocalDateTime expiresAt = now.plusMinutes(EXPIRE_MINUTES); - passwordMapper.upsertPasswordOtp(email, codeHash, DEFAULT_ATTEMPTS, false, expiresAt, now); + //passwordMapper.upsertPasswordOtp(email, codeHash, DEFAULT_ATTEMPTS, false, expiresAt, now); + PasswordVerification verification = passwordRepository.findByEmail(email) + .map(existing -> existing.updateOtp(codeHash, DEFAULT_ATTEMPTS, false, expiresAt)) + .orElseGet(() -> PasswordVerification.createNew(email, codeHash, DEFAULT_ATTEMPTS, false, expiresAt)); + + passwordRepository.save(verification); String title = "InsightPrep 비밀번호 재설정 인증 코드"; String content = """ @@ -74,47 +81,18 @@ public void requestOtp(String email) { @Override @Transactional public String verifyOtp(String email, String inputCode) { - // 1. 저장된 OTP 가져오기 - PasswordVerification otp = passwordMapper.findByEmail(email); - if (otp == null) { - throw new AuthException(AuthErrorCode.CODE_NOT_MATCH_ERROR); - } - - // 2. 이미 사용 여부 - if (otp.isUsed()) { - throw new AuthException(AuthErrorCode.OTP_ALREADY_USED); - } - - // 3. 만료 여부 - if (otp.getExpiresAt().isBefore(LocalDateTime.now())) { - throw new AuthException(AuthErrorCode.EXPIRED_CODE_ERROR); - } - - // 4. 코드 일치 여부 검증 - boolean matched = securityUtil.matches(inputCode, otp.getCodeHash()); - if (!matched) { - int remaining = otp.getAttemptsLeft() - 1; - if (remaining <= 0) { - passwordMapper.updateOtpAsUsed(email); // 실패 누적 → 사용 불가 - throw new AuthException(AuthErrorCode.OTP_INVALID); - } else { - passwordMapper.updateAttempts(email, remaining); - throw new AuthException(AuthErrorCode.OTP_INVALID_ATTEMPT); - } - } + PasswordVerification otp = passwordRepository.findByEmail(email) + .orElseThrow(() -> new AuthException(AuthErrorCode.CODE_NOT_MATCH_ERROR)); - // 5. 성공 처리: OTP 사용 처리 및 비밀번호 재설정 토큰 발급 - passwordMapper.updateOtpAsUsed(email); + // 엔티티 스스로 검증 및 상태 변경 수행 + otp.validateOtp(securityUtil, inputCode); + // OTP 성공 시: 사용 처리 & 비밀번호 재설정 토큰 발급 + otp.markOtpUsed(); String token = UUID.randomUUID().toString(); - // 유효시간은 OTP와 별도로 관리(예: 15분) - LocalDateTime tokenExpiresAt = LocalDateTime.now().plusMinutes(15); - int n = passwordMapper.updateResetToken(email, token, false, tokenExpiresAt); - - if (n != 1) { - throw new AuthException(AuthErrorCode.OTP_INVALID_ATTEMPT); - } + otp.issueResetToken(token, LocalDateTime.now().plusMinutes(15)); + // JPA가 변경 감지 → UPDATE 자동 수행 return token; } @@ -122,36 +100,32 @@ public String verifyOtp(String email, String inputCode) { @Transactional public void resetPassword(String resetToken, String newRawPassword) { // 1) 토큰으로 레코드 조회 - PasswordVerification row = passwordMapper.findByResetToken(resetToken); - if (row == null) { - throw new AuthException(AuthErrorCode.RESET_TOKEN_INVALID); - } + PasswordVerification verification = passwordRepository.findByResetToken(resetToken) + .orElseThrow(() -> new AuthException(AuthErrorCode.RESET_TOKEN_INVALID)); // 2) 토큰 사용 여부/만료 검사 - if (row.isResetUsed()) { + if (verification.isResetUsed()) { throw new AuthException(AuthErrorCode.RESET_TOKEN_ALREADY_USED); } - if (row.getResetExpiresAt() == null || row.getResetExpiresAt().isBefore(LocalDateTime.now())) { + if (verification.getResetExpiresAt() == null || verification.getResetExpiresAt().isBefore(LocalDateTime.now())) { throw new AuthException(AuthErrorCode.RESET_TOKEN_EXPIRED); } // 3) 멤버 조회(존재 확인) - String email = row.getEmail(); + String email = verification.getEmail(); Member member = authRepository.findByEmail(email).orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); // 4) 패스워드 해시 & 저장 - String hashed = securityUtil.encode(newRawPassword); - int updated = authRepository.updatePasswordByEmail(email, hashed); - if (updated != 1) { + try { + String hashed = securityUtil.encode(newRawPassword); + member.updatePassword(hashed); + } catch (RuntimeException e) { throw new AuthException(AuthErrorCode.SERVER_ERROR); } - // 5) 토큰 1회용 처리 + 무효화(선호에 따라 토큰 null 로도) - int done = passwordMapper.markResetTokenUsed(resetToken); - if (done != 1) { - // 여기서 실패하면 롤백 유도 - throw new AuthException(AuthErrorCode.SERVER_ERROR); - } + + // 5) 토큰 1회용 처리 (도메인 책임) + verification.markResetTokenUsed(); // JPA dirty checking으로 자동 업데이트 } // 대문자+숫자 6자리 diff --git a/src/main/java/com/project/InsightPrep/domain/member/entity/Member.java b/src/main/java/com/project/InsightPrep/domain/member/entity/Member.java index 8879cea..2510d4c 100644 --- a/src/main/java/com/project/InsightPrep/domain/member/entity/Member.java +++ b/src/main/java/com/project/InsightPrep/domain/member/entity/Member.java @@ -43,4 +43,7 @@ public class Member extends BaseTimeEntity { @Column(nullable = false) private int dailyLimit = 10; + public void updatePassword(String newHashedPassword) { + this.password = newHashedPassword; + } } diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java index c336d59..cb815df 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java @@ -1,10 +1,12 @@ package com.project.InsightPrep.domain.auth.service; import com.project.InsightPrep.domain.auth.entity.PasswordVerification; +import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; +import com.project.InsightPrep.domain.auth.repository.PasswordRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.mail.MessagingException; @@ -21,6 +23,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -29,6 +35,8 @@ class PasswordResetServiceImplTest { @Mock EmailService emailService; @Mock PasswordMapper passwordMapper; + @Mock + PasswordRepository passwordRepository; @Mock AuthMapper authMapper; @Mock AuthRepository authRepository; @@ -48,21 +56,22 @@ void setUp() { @DisplayName("requestOtp: 가입된 이메일이면 OTP 저장 및 메일 발송") void requestOtp_sendsMail_whenEmailExists() throws Exception { when(authRepository.existsByEmail(EMAIL)).thenReturn(true); + when(passwordRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); // execute service.requestOtp(EMAIL); - // verify: DB upsert & email sent - verify(passwordMapper).upsertPasswordOtp( - eq(EMAIL), - anyString(), // codeHash (BCrypt) - eq(5), // DEFAULT_ATTEMPTS - eq(false), // used - any(LocalDateTime.class), // expiresAt - any(LocalDateTime.class) // createdAt - ); + // Repository save() 호출 검증 + verify(passwordRepository).save(argThat(saved -> { + assertEquals(EMAIL, saved.getEmail()); + assertFalse(saved.isUsed()); + assertEquals(5, saved.getAttemptsLeft()); + assertNotNull(saved.getCodeHash()); + assertTrue(saved.getExpiresAt().isAfter(LocalDateTime.now())); + return true; + })); verify(emailService).sendEmail(eq(EMAIL), anyString(), contains("비밀번호 재설정 인증 코드")); - verifyNoMoreInteractions(emailService, passwordMapper); + verifyNoMoreInteractions(emailService, passwordRepository); } @Test @@ -92,7 +101,7 @@ void requestOtp_throws_whenSendMailFails() throws Exception { @Test @DisplayName("verifyOtp: 정상 - 코드 일치 시 사용 처리 후 resetToken 발급 및 저장") void verifyOtp_success_issueResetToken() { - PasswordVerification row = otpRow(false, // used + PasswordVerification otp = otpRow(false, // used LocalDateTime.now().plusMinutes(5), "$2a$10$hash", // stored hash 5, // attempts @@ -101,72 +110,73 @@ void verifyOtp_success_issueResetToken() { null // resetToken ); - when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(passwordRepository.findByEmail(EMAIL)).thenReturn(Optional.of(otp)); when(securityUtil.matches(eq("ABC123"), anyString())).thenReturn(true); - when(passwordMapper.updateResetToken(eq(EMAIL), anyString(), eq(false), any(LocalDateTime.class))) - .thenReturn(1); String token = service.verifyOtp(EMAIL, "ABC123"); assertThat(token).isNotBlank(); - verify(passwordMapper).updateOtpAsUsed(EMAIL); - verify(passwordMapper).updateResetToken(eq(EMAIL), anyString(), eq(false), any(LocalDateTime.class)); + assertThat(otp.isUsed()).isTrue(); + assertThat(otp.getResetToken()).isNotBlank(); + assertThat(otp.getResetExpiresAt()).isAfter(LocalDateTime.now()); } @Test @DisplayName("verifyOtp: 코드 불일치(남은 시도 > 0) → attempts 감소 후 예외") void verifyOtp_decreaseAttempts_andThrow() { - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), "$2a$10$hash", 3, false, null, null); - when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(passwordRepository.findByEmail(EMAIL)).thenReturn(Optional.of(otp)); when(securityUtil.matches(eq("WRONG"), anyString())).thenReturn(false); assertThatThrownBy(() -> service.verifyOtp(EMAIL, "WRONG")) .isInstanceOf(AuthException.class); - verify(passwordMapper).updateAttempts(EMAIL, 2); - verify(passwordMapper, never()).updateOtpAsUsed(anyString()); + assertThat(otp.getAttemptsLeft()).isEqualTo(2); + assertThat(otp.isUsed()).isFalse(); } @Test @DisplayName("verifyOtp: 코드 불일치(마지막 시도) → used 처리 후 OTP_INVALID") void verifyOtp_lastAttempt_marksUsed_andThrows() { - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), "$2a$10$hash", 1, false, null, null); - when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(passwordRepository.findByEmail(EMAIL)).thenReturn(Optional.of(otp)); when(securityUtil.matches(eq("WRONG"), anyString())).thenReturn(false); assertThatThrownBy(() -> service.verifyOtp(EMAIL, "WRONG")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.OTP_INVALID.getMessage()); - verify(passwordMapper).updateOtpAsUsed(EMAIL); - verify(passwordMapper, never()).updateAttempts(anyString(), anyInt()); + assertThat(otp.isUsed()).isTrue(); } @Test @DisplayName("verifyOtp: 이미 사용된 OTP") void verifyOtp_used_throws() { - PasswordVerification row = otpRow(true, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(true, LocalDateTime.now().plusMinutes(5), "$2a$10$hash", 5, false, null, null); - when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(passwordRepository.findByEmail(EMAIL)).thenReturn(Optional.of(otp)); assertThatThrownBy(() -> service.verifyOtp(EMAIL, "ANY")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.OTP_ALREADY_USED.getMessage()); } @Test @DisplayName("verifyOtp: 만료된 OTP") void verifyOtp_expired_throws() { - PasswordVerification row = otpRow(false, LocalDateTime.now().minusSeconds(1), + PasswordVerification otp = otpRow(false, LocalDateTime.now().minusSeconds(1), "$2a$10$hash", 5, false, null, null); - when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(passwordRepository.findByEmail(EMAIL)).thenReturn(Optional.of(otp)); assertThatThrownBy(() -> service.verifyOtp(EMAIL, "ANY")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.EXPIRED_CODE_ERROR.getMessage()); } // ---------- resetPassword ---------- @@ -174,98 +184,84 @@ void verifyOtp_expired_throws() { @DisplayName("resetPassword: 정상 - 토큰 유효, 비번 업데이트 및 토큰 사용 처리") void resetPassword_success() { String token = UUID.randomUUID().toString(); - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), null, 0, false, LocalDateTime.now().plusMinutes(10), token); - row.builder().email(EMAIL).build(); - when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + Member member = Member.builder().email(EMAIL).build(); + + when(passwordRepository.findByResetToken(token)).thenReturn(Optional.of(otp)); + when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); when(securityUtil.encode("newP@ss!")).thenReturn("hashed"); - when(authRepository.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); - when(passwordMapper.markResetTokenUsed(token)).thenReturn(1); service.resetPassword(token, "newP@ss!"); - verify(authRepository).updatePasswordByEmail(EMAIL, "hashed"); - verify(passwordMapper).markResetTokenUsed(token); + assertThat(member.getPassword()).isEqualTo("hashed"); + assertThat(otp.isResetUsed()).isTrue(); + assertThat(otp.getResetToken()).isNull(); + assertThat(otp.getResetExpiresAt()).isNull(); } @Test @DisplayName("resetPassword: 토큰 조회 불가 → RESET_TOKEN_INVALID") void resetPassword_tokenNotFound() { - when(passwordMapper.findByResetToken("bad")).thenReturn(null); + when(passwordRepository.findByResetToken("bad")).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.resetPassword("bad", "pw")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.RESET_TOKEN_INVALID.getMessage()); } @Test @DisplayName("resetPassword: 이미 사용된 토큰 → RESET_TOKEN_ALREADY_USED") void resetPassword_tokenAlreadyUsed() { - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), null, 0, true, LocalDateTime.now().plusMinutes(10), "token"); - when(passwordMapper.findByResetToken("token")).thenReturn(row); + when(passwordRepository.findByResetToken("token")).thenReturn(Optional.of(otp)); assertThatThrownBy(() -> service.resetPassword("token", "pw")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.RESET_TOKEN_ALREADY_USED.getMessage()); } @Test @DisplayName("resetPassword: 만료된 토큰 → RESET_TOKEN_EXPIRED") void resetPassword_tokenExpired() { - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), null, 0, false, LocalDateTime.now().minusSeconds(1), "token"); - when(passwordMapper.findByResetToken("token")).thenReturn(row); + when(passwordRepository.findByResetToken("token")).thenReturn(Optional.of(otp)); assertThatThrownBy(() -> service.resetPassword("token", "pw")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.RESET_TOKEN_EXPIRED.getMessage()); } @Test @DisplayName("resetPassword: 회원 없음 → MEMBER_NOT_FOUND") void resetPassword_memberNotFound() { String token = "token"; - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), null, 0, false, LocalDateTime.now().plusMinutes(10), token); - row.builder().email(EMAIL).build(); - when(passwordMapper.findByResetToken(token)).thenReturn(row); + when(passwordRepository.findByResetToken(token)).thenReturn(Optional.of(otp)); when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.resetPassword(token, "pw")) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .hasMessageContaining(AuthErrorCode.MEMBER_NOT_FOUND.getMessage()); } @Test - @DisplayName("resetPassword: 비밀번호 업데이트 실패 → SERVER_ERROR") - void resetPassword_updatePasswordFail() { + @DisplayName("resetPassword: 비밀번호 인코딩 실패 → SERVER_ERROR") + void resetPassword_encodeFail() { String token = "token"; - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + PasswordVerification otp = otpRow(false, LocalDateTime.now().plusMinutes(5), null, 0, false, LocalDateTime.now().plusMinutes(10), token); - row.builder().email(EMAIL).build(); - - when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); - when(securityUtil.encode("pw")).thenReturn("hashed"); - when(authRepository.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(0); - assertThatThrownBy(() -> service.resetPassword(token, "pw")) - .isInstanceOf(AuthException.class); - } - - @Test - @DisplayName("resetPassword: 토큰 사용 처리 실패 → SERVER_ERROR") - void resetPassword_markTokenFail() { - String token = "token"; - PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), - null, 0, false, LocalDateTime.now().plusMinutes(10), token); - row.builder().email(EMAIL).build(); + Member member = Member.builder().email(EMAIL).build(); - when(passwordMapper.findByResetToken(token)).thenReturn(row); - when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); - when(securityUtil.encode("pw")).thenReturn("hashed"); - when(authRepository.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); - when(passwordMapper.markResetTokenUsed(token)).thenReturn(0); + when(passwordRepository.findByResetToken(token)).thenReturn(Optional.of(otp)); + when(authRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + when(securityUtil.encode("pw")).thenThrow(new RuntimeException(AuthErrorCode.SERVER_ERROR.getMessage())); assertThatThrownBy(() -> service.resetPassword(token, "pw")) .isInstanceOf(AuthException.class); From c1d954061fe1668507c94b528387749d6c4e8f42 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 13 Oct 2025 00:40:11 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20Question=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/entity/Question.java | 6 ++-- .../repository/QuestionRepository.java | 13 +++++++++ .../scheduler/QuestionCleanupScheduler.java | 9 ++++-- .../service/impl/AnswerServiceImpl.java | 6 ++-- .../service/impl/QuestionServiceImpl.java | 4 ++- .../service/impl/AnswerServiceImplTest.java | 28 +++++++++++++++---- .../service/impl/QuestionServiceImplTest.java | 14 ++++++---- 7 files changed, 62 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/question/repository/QuestionRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java b/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java index bdfe974..ee85163 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java +++ b/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java @@ -28,8 +28,6 @@ public class Question extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // TODO: 관련 기업 - 등록된 기업에 한해서 선택 가능 (기업 선택 관련 기능은 추후 추가 예정) - @Column(nullable = false) private String category; // OS, DB 등 @@ -39,4 +37,8 @@ public class Question extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) private AnswerStatus status = AnswerStatus.WAITING; + + public void markAsAnswered() { + this.status = AnswerStatus.ANSWERED; + } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/repository/QuestionRepository.java b/src/main/java/com/project/InsightPrep/domain/question/repository/QuestionRepository.java new file mode 100644 index 0000000..ebcb0cd --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/repository/QuestionRepository.java @@ -0,0 +1,13 @@ +package com.project.InsightPrep.domain.question.repository; + +import com.project.InsightPrep.domain.question.entity.AnswerStatus; +import com.project.InsightPrep.domain.question.entity.Question; +import java.time.LocalDateTime; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface QuestionRepository extends JpaRepository { + + @Transactional + void deleteByStatusAndCreatedAtBefore(AnswerStatus status, LocalDateTime cutoff); +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java b/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java index 384de50..46d2d70 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java +++ b/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java @@ -2,18 +2,23 @@ import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.QuestionRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor +@Slf4j public class QuestionCleanupScheduler { - private final QuestionMapper questionMapper; + private final QuestionRepository questionRepository; @Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시에 실행 public void deleteOldUnansweredQuestions() { - questionMapper.deleteUnansweredQuestions(AnswerStatus.WAITING.name()); + questionRepository.deleteByStatusAndCreatedAtBefore(AnswerStatus.WAITING, LocalDateTime.now().minusHours(1)); + log.info("1시간 이상 지난 미답변 질문 삭제 완료"); } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java index 5f02800..8df9355 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java @@ -12,6 +12,7 @@ import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.AnswerService; import com.project.InsightPrep.domain.question.service.FeedbackService; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -28,6 +29,7 @@ public class AnswerServiceImpl implements AnswerService { private final SecurityUtil securityUtil; private final QuestionMapper questionMapper; + private final QuestionRepository questionRepository; private final AnswerMapper answerMapper; private final FeedbackService feedbackService; private final ApplicationEventPublisher eventPublisher; @@ -36,7 +38,7 @@ public class AnswerServiceImpl implements AnswerService { @Transactional public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { Member member = securityUtil.getAuthenticatedMember(); - Question question = questionMapper.findById(questionId); + Question question = questionRepository.findById(questionId).orElseThrow(() -> new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND)); Answer answer = Answer.builder() .member(member) @@ -44,7 +46,7 @@ public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { .content(dto.getContent()) .build(); - questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name()); + question.markAsAnswered(); answerMapper.insertAnswer(answer); //feedbackService.saveFeedback(answer); eventPublisher.publishEvent(new AnswerSavedEvent(answer)); diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java index 070241b..1a15e4e 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java @@ -10,6 +10,7 @@ import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.QuestionService; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -30,6 +31,7 @@ public class QuestionServiceImpl implements QuestionService { private final GptService gptService; private final QuestionMapper questionMapper; + private final QuestionRepository questionRepository; private final AnswerMapper answerMapper; private final RecentPromptFilterService recentPromptFilterService; private final SecurityUtil securityUtil; @@ -57,7 +59,7 @@ public QuestionDto createQuestion(String category) { .status(AnswerStatus.WAITING) .build(); - questionMapper.insertQuestion(question); + questionRepository.save(question); // 5) 기록 (Redis + DB) - 응답에 topic/keyword가 비어있을 수도 있으므로 방어 if (isNotBlank(gptQuestion.getTopic())) { diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java index ccbdf33..cc404e4 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java @@ -25,8 +25,10 @@ import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.lang.reflect.Field; +import java.util.Optional; import java.util.concurrent.TimeUnit; import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; @@ -50,6 +52,9 @@ class AnswerServiceImplTest { @Mock private QuestionMapper questionMapper; + @Mock + private QuestionRepository questionRepository; + @Mock private AnswerMapper answerMapper; @@ -81,9 +86,8 @@ void saveAnswer_success() { AnswerDto dto = new AnswerDto("테스트 답변입니다."); when(securityUtil.getAuthenticatedMember()).thenReturn(mockMember); - when(questionMapper.findById(questionId)).thenReturn(mockQuestion); + when(questionRepository.findById(questionId)).thenReturn(Optional.of(mockQuestion)); - doNothing().when(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name())); // insertAnswer 호출 시, DB가 생성한 PK가 들어간 것처럼 id 세팅을 시뮬레이션 doAnswer(invocation -> { Answer arg = invocation.getArgument(0); @@ -98,10 +102,11 @@ void saveAnswer_success() { // then verify(securityUtil).getAuthenticatedMember(); - verify(questionMapper).findById(questionId); - verify(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name())); + verify(questionRepository).findById(questionId); verify(answerMapper).insertAnswer(any(Answer.class)); + assertThat(mockQuestion.getStatus()).isEqualTo(AnswerStatus.ANSWERED); + // 이벤트 객체 캡처 ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AnswerSavedEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); @@ -117,7 +122,7 @@ void saveAnswer_success() { // 반환 DTO 검증 (서비스가 DTO를 반환하도록 구현되어 있다는 가정) assertThat(res).isNotNull(); assertThat(res.getAnswerId()).isEqualTo(100L); - verifyNoMoreInteractions(securityUtil, questionMapper, answerMapper, feedbackService); + verifyNoMoreInteractions(securityUtil, questionRepository, answerMapper, feedbackService); } @Test @@ -172,12 +177,21 @@ void saveAnswer_shouldPublishEvent_andTriggerFeedbackListener() { Long questionId = 1L; AnswerDto dto = new AnswerDto("테스트 답변"); + Question mockQuestion = Question.builder() + .id(questionId) + .category("DB") + .content("질문 내용") + .status(AnswerStatus.WAITING) + .build(); + + when(questionRepository.findById(questionId)).thenReturn(Optional.of(mockQuestion)); + doAnswer(invocation -> { Answer arg = invocation.getArgument(0); Field idField = Answer.class.getDeclaredField("id"); idField.setAccessible(true); idField.set(arg, 123L); // PK 강제 설정 - return null; + return arg; }).when(answerMapper).insertAnswer(any(Answer.class)); // when @@ -187,5 +201,7 @@ void saveAnswer_shouldPublishEvent_andTriggerFeedbackListener() { Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> verify(eventPublisher, times(1)).publishEvent(any(AnswerSavedEvent.class)) ); + + assertThat(mockQuestion.getStatus()).isEqualTo(AnswerStatus.ANSWERED); } } \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java index 6cde33a..a605995 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java @@ -22,6 +22,7 @@ import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import com.project.InsightPrep.global.auth.util.SecurityUtil; import com.project.InsightPrep.global.gpt.service.GptService; @@ -44,6 +45,9 @@ class QuestionServiceImplTest { @Mock private QuestionMapper questionMapper; + @Mock + private QuestionRepository questionRepository; + @Mock private AnswerMapper answerMapper; @@ -81,14 +85,14 @@ void createQuestion_ShouldGenerateQuestionAndInsertIntoDatabase() { when(gptService.callOpenAI(any(), anyInt(), anyDouble(), any())) .thenReturn(mockGptQuestion); - // insert 시 id 주입 + // save() 호출 시 반환할 엔티티 mock doAnswer(invocation -> { Question q = invocation.getArgument(0); Field idField = Question.class.getDeclaredField("id"); idField.setAccessible(true); idField.set(q, 123L); - return null; - }).when(questionMapper).insertQuestion(any(Question.class)); + return q; // ✅ 중요: 동일 객체 반환 + }).when(questionRepository).save(any(Question.class)); // when QuestionResponse.QuestionDto result = questionService.createQuestion(category); @@ -101,12 +105,12 @@ void createQuestion_ShouldGenerateQuestionAndInsertIntoDatabase() { assertEquals(AnswerStatus.WAITING, result.getStatus()); // 핵심 상호작용 검증 - InOrder inOrder = inOrder(securityUtil, recentPromptFilterService, gptService, questionMapper); + InOrder inOrder = inOrder(securityUtil, recentPromptFilterService, gptService, questionRepository); inOrder.verify(securityUtil).getLoginMemberId(); inOrder.verify(recentPromptFilterService).getRecent(memberId, category, ItemType.TOPIC, 10); inOrder.verify(recentPromptFilterService).getRecent(memberId, category, ItemType.KEYWORD, 10); inOrder.verify(gptService).callOpenAI(any(), anyInt(), anyDouble(), any()); - inOrder.verify(questionMapper).insertQuestion(any(Question.class)); + inOrder.verify(questionRepository).save(any(Question.class)); inOrder.verify(recentPromptFilterService).record(memberId, category, ItemType.TOPIC, "프로세스 vs 스레드"); inOrder.verify(recentPromptFilterService).record(memberId, category, ItemType.KEYWORD, "thread"); From 61171267f9333bcde15fef669ed81db3f80fecf0 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 13 Oct 2025 03:14:49 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20Answer=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SharedPostServiceImpl.java | 4 +- .../dto/response/QuestionResponse.java | 2 + .../domain/question/entity/Answer.java | 5 ++ .../domain/question/entity/Question.java | 4 ++ .../question/repository/AnswerRepository.java | 28 +++++++++ .../service/impl/AnswerServiceImpl.java | 38 +++++++----- .../service/impl/QuestionServiceImpl.java | 32 ++++++++-- .../impl/SharedPostServiceImplTest.java | 10 ++- .../service/impl/AnswerServiceImplTest.java | 39 +++++++----- .../service/impl/QuestionServiceImplTest.java | 61 ++++++++++--------- 10 files changed, 159 insertions(+), 64 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/question/repository/AnswerRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java index 166726c..b113322 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java @@ -11,6 +11,7 @@ import com.project.InsightPrep.domain.post.service.SharedPostService; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; +import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.util.List; import lombok.RequiredArgsConstructor; @@ -26,13 +27,14 @@ public class SharedPostServiceImpl implements SharedPostService { private final SecurityUtil securityUtil; private final SharedPostMapper sharedPostMapper; private final AnswerMapper answerMapper; + private final AnswerRepository answerRepository; @Override @Transactional public Long createPost(Create req) { long memberId = securityUtil.getLoginMemberId(); - boolean myAnswer = answerMapper.existsMyAnswer(req.getAnswerId(), memberId); + boolean myAnswer = answerRepository.existsByIdAndMemberId(req.getAnswerId(), memberId); if (!myAnswer) { throw new PostException(PostErrorCode.FORBIDDEN_OR_NOT_FOUND_ANSWER); } diff --git a/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java b/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java index 176b145..4734bf8 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java +++ b/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.project.InsightPrep.domain.question.entity.AnswerStatus; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -31,6 +32,7 @@ public static class GptQuestion { @Getter @Builder @JsonInclude(Include.NON_NULL) + @AllArgsConstructor public static class QuestionsDto { private long questionId; private String category; diff --git a/src/main/java/com/project/InsightPrep/domain/question/entity/Answer.java b/src/main/java/com/project/InsightPrep/domain/question/entity/Answer.java index 0805105..cbde8b2 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/entity/Answer.java +++ b/src/main/java/com/project/InsightPrep/domain/question/entity/Answer.java @@ -2,6 +2,7 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.common.entity.BaseTimeEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -10,6 +11,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -43,4 +45,7 @@ public class Answer extends BaseTimeEntity { @Column(columnDefinition = "TEXT", nullable = false) private String content; + + @OneToOne(mappedBy = "answer", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private AnswerFeedback feedback; } diff --git a/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java b/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java index ee85163..e567c57 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java +++ b/src/main/java/com/project/InsightPrep/domain/question/entity/Question.java @@ -41,4 +41,8 @@ public class Question extends BaseTimeEntity { public void markAsAnswered() { this.status = AnswerStatus.ANSWERED; } + + public void markAsWaiting() { + this.status = AnswerStatus.WAITING; + } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/repository/AnswerRepository.java b/src/main/java/com/project/InsightPrep/domain/question/repository/AnswerRepository.java new file mode 100644 index 0000000..cf9552c --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/repository/AnswerRepository.java @@ -0,0 +1,28 @@ +package com.project.InsightPrep.domain.question.repository; + +import com.project.InsightPrep.domain.question.entity.Answer; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AnswerRepository extends JpaRepository { + + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.question q + JOIN FETCH AnswerFeedback f ON f.answer = a + WHERE a.member.id = :memberId + ORDER BY a.id DESC + """) + List findAllWithQuestionAndFeedbackByMemberId(@Param("memberId") Long memberId, Pageable pageable); + + int countByQuestionId(Long questionId); + + Optional findByIdAndMemberId(Long answerId, Long memberId); + + boolean existsByIdAndMemberId(Long answerId, Long memberId); +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java index 8df9355..96c5dde 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java @@ -5,13 +5,13 @@ import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; import com.project.InsightPrep.domain.question.dto.response.PreviewResponse; import com.project.InsightPrep.domain.question.entity.Answer; -import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.event.AnswerSavedEvent; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.AnswerService; import com.project.InsightPrep.domain.question.service.FeedbackService; @@ -19,6 +19,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +32,7 @@ public class AnswerServiceImpl implements AnswerService { private final QuestionMapper questionMapper; private final QuestionRepository questionRepository; private final AnswerMapper answerMapper; + private final AnswerRepository answerRepository; private final FeedbackService feedbackService; private final ApplicationEventPublisher eventPublisher; @@ -47,7 +49,7 @@ public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { .build(); question.markAsAnswered(); - answerMapper.insertAnswer(answer); + answerRepository.save(answer); //feedbackService.saveFeedback(answer); eventPublisher.publishEvent(new AnswerSavedEvent(answer)); return AnswerResponse.AnswerDto.builder() @@ -59,27 +61,35 @@ public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { public void deleteAnswer(long answerId) { long memberId = securityUtil.getLoginMemberId(); - Long questionId = answerMapper.findQuestionIdOfMyAnswer(answerId, memberId); - if (questionId == null) { - throw new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND); - } + Answer answer = answerRepository.findById(answerId) + .filter(a -> a.getMember().getId().equals(memberId)) + .orElseThrow(() -> new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND)); + + Long questionId = answer.getQuestion().getId(); - int del = answerMapper.deleteMyAnswerById(answerId, memberId); - if (del == 0) { + try { + answerRepository.delete(answer); + } catch (EmptyResultDataAccessException e) { throw new QuestionException(QuestionErrorCode.ALREADY_DELETED); } - answerMapper.resetQuestionStatusIfNoAnswers(questionId, AnswerStatus.WAITING.name()); + if (answerRepository.countByQuestionId(questionId) == 0) { + Question question = questionRepository.findById(questionId) + .orElseThrow(() -> new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND)); + question.markAsWaiting(); + } } @Override @Transactional(readOnly = true) public PreviewResponse getPreview(long answerId) { long memberId = securityUtil.getLoginMemberId(); - PreviewResponse res = answerMapper.findMyPreviewByAnswerId(answerId, memberId); - if (res == null) { - throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND); - } - return res; + Answer answer = answerRepository.findByIdAndMemberId(answerId, memberId).orElseThrow(() -> new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND)); + Question question = answer.getQuestion(); + + return PreviewResponse.builder() + .question(question.getContent()) + .answer(answer.getContent()) + .build(); } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java index 1a15e4e..51ad55e 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java @@ -5,11 +5,14 @@ import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.GptQuestion; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionDto; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; +import com.project.InsightPrep.domain.question.entity.Answer; +import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.QuestionService; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; @@ -21,6 +24,8 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +38,7 @@ public class QuestionServiceImpl implements QuestionService { private final QuestionMapper questionMapper; private final QuestionRepository questionRepository; private final AnswerMapper answerMapper; + private final AnswerRepository answerRepository; private final RecentPromptFilterService recentPromptFilterService; private final SecurityUtil securityUtil; @@ -84,11 +90,29 @@ public PageResponse getQuestions(int page, int size) { int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 50); - int offset = (safePage - 1) * safeSize; - List content = answerMapper.findQuestionsWithFeedbackPaged(memberId, safeSize, offset); - long total = answerMapper.countQuestionsWithFeedback(memberId); - return PageResponse.of(content, safePage, safeSize, total); + Pageable pageable = PageRequest.of(safePage - 1, safeSize); + + List answers = answerRepository.findAllWithQuestionAndFeedbackByMemberId(memberId, pageable); + + List dtos = answers.stream() + .map(a -> { + AnswerFeedback f = a.getFeedback(); // fetch join으로 이미 로드됨 + return QuestionResponse.QuestionsDto.builder() + .questionId(a.getQuestion().getId()) + .category(a.getQuestion().getCategory()) + .question(a.getQuestion().getContent()) + .answerId(a.getId()) + .answer(a.getContent()) + .feedbackId(f.getId()) + .score(f.getScore()) + .improvement(f.getImprovement()) + .modelAnswer(f.getModelAnswer()) + .build(); + }) + .toList(); + + return PageResponse.of(dtos, safePage, safeSize, dtos.size()); } private boolean hasAny(List a, List b) { diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java index 66647b0..2e0c81f 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java @@ -20,6 +20,7 @@ import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; +import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.time.LocalDateTime; import java.util.List; @@ -42,6 +43,9 @@ class SharedPostServiceImplTest { @Mock AnswerMapper answerMapper; + @Mock + AnswerRepository answerRepository; + @InjectMocks SharedPostServiceImpl service; @@ -58,7 +62,7 @@ void createPost_success() { .build(); given(securityUtil.getLoginMemberId()).willReturn(memberId); - given(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(true); + given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(true); given(sharedPostMapper.insertSharedPost(eq("t"), eq("c"), eq(answerId), eq(memberId), eq(PostStatus.OPEN.name()))) .willReturn(1); given(sharedPostMapper.lastInsertedId()).willReturn(999L); @@ -83,7 +87,7 @@ void createPost_forbiddenOrNotFoundAnswer() { .build(); given(securityUtil.getLoginMemberId()).willReturn(memberId); - given(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(false); + given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(false); assertThatThrownBy(() -> service.createPost(req)) .isInstanceOf(PostException.class) @@ -105,7 +109,7 @@ void createPost_createFailed() { .build(); given(securityUtil.getLoginMemberId()).willReturn(memberId); - given(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(true); + given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(true); given(sharedPostMapper.insertSharedPost(anyString(), anyString(), anyLong(), anyLong(), anyString())) .willReturn(0); diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java index cc404e4..20b4b82 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -25,6 +26,7 @@ import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.lang.reflect.Field; @@ -39,6 +41,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.EmptyResultDataAccessException; @ExtendWith(MockitoExtension.class) class AnswerServiceImplTest { @@ -58,6 +61,9 @@ class AnswerServiceImplTest { @Mock private AnswerMapper answerMapper; + @Mock + private AnswerRepository answerRepository; + @Mock private FeedbackServiceImpl feedbackService; @@ -95,7 +101,7 @@ void saveAnswer_success() { idField.setAccessible(true); idField.set(arg, 100L); return null; - }).when(answerMapper).insertAnswer(any(Answer.class)); + }).when(answerRepository).save(any(Answer.class)); // when AnswerResponse.AnswerDto res = answerService.saveAnswer(dto, questionId); @@ -103,7 +109,7 @@ void saveAnswer_success() { // then verify(securityUtil).getAuthenticatedMember(); verify(questionRepository).findById(questionId); - verify(answerMapper).insertAnswer(any(Answer.class)); + verify(answerRepository).save(any(Answer.class)); assertThat(mockQuestion.getStatus()).isEqualTo(AnswerStatus.ANSWERED); @@ -133,7 +139,7 @@ void deleteAnswer_questionNotFound() { long answerId = 100L; when(securityUtil.getLoginMemberId()).thenReturn(memberId); - when(answerMapper.findQuestionIdOfMyAnswer(answerId, memberId)).thenReturn(null); + when(answerRepository.findById(answerId)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> answerService.deleteAnswer(answerId)) @@ -141,10 +147,11 @@ void deleteAnswer_questionNotFound() { .matches(ex -> ((QuestionException) ex).getErrorCode() == QuestionErrorCode.QUESTION_NOT_FOUND); verify(securityUtil).getLoginMemberId(); - verify(answerMapper).findQuestionIdOfMyAnswer(answerId, memberId); - verify(answerMapper, never()).deleteMyAnswerById(anyLong(), anyLong()); - verify(answerMapper, never()).resetQuestionStatusIfNoAnswers(anyLong(), anyString()); - verifyNoMoreInteractions(answerMapper, securityUtil); + verify(answerRepository).findById(answerId); + verify(answerRepository, never()).delete(any()); + verify(questionRepository, never()).findById(anyLong()); + verify(questionRepository, never()).save(any()); + verifyNoMoreInteractions(answerRepository, questionRepository, securityUtil); } @Test @@ -155,9 +162,14 @@ void deleteAnswer_alreadyDeleted() { long answerId = 100L; long questionId = 10L; + Member mockMember = Member.builder().id(memberId).build(); + Question mockQuestion = Question.builder().id(questionId).category("OS").content("Q").build(); + Answer mockAnswer = Answer.builder().id(answerId).member(mockMember).question(mockQuestion).content("A").build(); + when(securityUtil.getLoginMemberId()).thenReturn(memberId); - when(answerMapper.findQuestionIdOfMyAnswer(answerId, memberId)).thenReturn(questionId); - when(answerMapper.deleteMyAnswerById(answerId, memberId)).thenReturn(0); // 영향 0 → 이미 삭제 + when(answerRepository.findById(answerId)).thenReturn(Optional.of(mockAnswer)); + // 삭제 도중 예외 발생 확인 (이미 삭제된 상태 가정) + doThrow(new EmptyResultDataAccessException(1)).when(answerRepository).delete(mockAnswer); // when & then assertThatThrownBy(() -> answerService.deleteAnswer(answerId)) @@ -165,10 +177,9 @@ void deleteAnswer_alreadyDeleted() { .matches(ex -> ((QuestionException) ex).getErrorCode() == QuestionErrorCode.ALREADY_DELETED); verify(securityUtil).getLoginMemberId(); - verify(answerMapper).findQuestionIdOfMyAnswer(answerId, memberId); - verify(answerMapper).deleteMyAnswerById(answerId, memberId); - verify(answerMapper, never()).resetQuestionStatusIfNoAnswers(anyLong(), anyString()); - verifyNoMoreInteractions(answerMapper, securityUtil); + verify(answerRepository).findById(answerId); + verify(answerRepository).delete(mockAnswer); + verifyNoMoreInteractions(answerRepository, questionRepository, securityUtil); } @Test @@ -192,7 +203,7 @@ void saveAnswer_shouldPublishEvent_andTriggerFeedbackListener() { idField.setAccessible(true); idField.set(arg, 123L); // PK 강제 설정 return arg; - }).when(answerMapper).insertAnswer(any(Answer.class)); + }).when(answerRepository).save(any(Answer.class)); // when answerService.saveAnswer(dto, questionId); diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java index a605995..789a6a1 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java @@ -17,11 +17,14 @@ import com.project.InsightPrep.domain.question.dto.response.QuestionResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.GptQuestion; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; +import com.project.InsightPrep.domain.question.entity.Answer; +import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -35,6 +38,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class QuestionServiceImplTest { @@ -51,6 +56,9 @@ class QuestionServiceImplTest { @Mock private AnswerMapper answerMapper; + @Mock + private AnswerRepository answerRepository; + @Mock private RecentPromptFilterService recentPromptFilterService; @@ -124,19 +132,22 @@ void getQuestions_paged_ok() { long memberId = 42L; int page = 2; int size = 10; - int offset = (Math.max(page, 1) - 1) * size; // 10 when(securityUtil.getLoginMemberId()).thenReturn(memberId); - var dto = QuestionsDto.builder() - .questionId(10L).category("NETWORK").question("TCP/UDP?") - .answerId(100L).answer("비교") - .feedbackId(1000L).score(90).modelAnswer("...").build(); + Question question = Question.builder() + .id(10L).category("NETWORK").content("TCP/UDP?") + .status(AnswerStatus.ANSWERED).build(); + + AnswerFeedback feedback = AnswerFeedback.builder() + .id(1000L).score(90).improvement("개선점").modelAnswer("...").build(); + + Answer answer = Answer.builder() + .id(100L).question(question).content("비교").feedback(feedback).build(); - when(answerMapper.findQuestionsWithFeedbackPaged(memberId, size, offset)) - .thenReturn(List.of(dto)); - when(answerMapper.countQuestionsWithFeedback(memberId)) - .thenReturn(23L); // 총 23건 → 10개씩이면 총 3페이지 + Pageable pageable = PageRequest.of(page - 1, size); + when(answerRepository.findAllWithQuestionAndFeedbackByMemberId(memberId, pageable)) + .thenReturn(List.of(answer)); // when PageResponse res = questionService.getQuestions(page, size); @@ -146,16 +157,17 @@ void getQuestions_paged_ok() { assertThat(res.getContent()).hasSize(1); assertThat(res.getPage()).isEqualTo(2); assertThat(res.getSize()).isEqualTo(10); - assertThat(res.getTotalElements()).isEqualTo(23L); - assertThat(res.getTotalPages()).isEqualTo(3L); - assertThat(res.isFirst()).isFalse(); - assertThat(res.isLast()).isFalse(); + assertThat(res.getTotalElements()).isEqualTo(1); // JPA Mock이므로 1건만 + assertThat(res.getTotalPages()).isEqualTo(1); - InOrder inOrder = inOrder(securityUtil, answerMapper); + assertThat(res.getContent().get(0).getQuestion()).isEqualTo("TCP/UDP?"); + assertThat(res.getContent().get(0).getAnswer()).isEqualTo("비교"); + assertThat(res.getContent().get(0).getScore()).isEqualTo(90); + + InOrder inOrder = inOrder(securityUtil, answerRepository); inOrder.verify(securityUtil).getLoginMemberId(); - inOrder.verify(answerMapper).findQuestionsWithFeedbackPaged(eq(memberId), eq(size), eq(offset)); - inOrder.verify(answerMapper).countQuestionsWithFeedback(eq(memberId)); - verifyNoMoreInteractions(answerMapper, securityUtil); + inOrder.verify(answerRepository).findAllWithQuestionAndFeedbackByMemberId(eq(memberId), any(Pageable.class)); + verifyNoMoreInteractions(answerRepository, securityUtil); } @Test @@ -166,28 +178,21 @@ void getQuestions_sizeCappedTo50() { int page = 1; int requestedSize = 100; // 사용자가 크게 요청 int safeSize = 50; // 서비스 로직 상한 - int offset = 0; when(securityUtil.getLoginMemberId()).thenReturn(memberId); - - when(answerMapper.findQuestionsWithFeedbackPaged(memberId, safeSize, offset)) - .thenReturn(List.of()); - when(answerMapper.countQuestionsWithFeedback(memberId)) - .thenReturn(0L); + when(answerRepository.findAllWithQuestionAndFeedbackByMemberId(eq(memberId), any(Pageable.class))).thenReturn(List.of()); // 빈 리스트 // when PageResponse res = questionService.getQuestions(page, requestedSize); // then assertThat(res.getPage()).isEqualTo(1); - assertThat(res.getSize()).isEqualTo(50); // 캡 확인 + assertThat(res.getSize()).isEqualTo(50); assertThat(res.getTotalElements()).isEqualTo(0L); - assertThat(res.getTotalPages()).isEqualTo(0L); assertThat(res.isFirst()).isTrue(); assertThat(res.isLast()).isTrue(); - verify(answerMapper).findQuestionsWithFeedbackPaged(memberId, safeSize, offset); - verify(answerMapper).countQuestionsWithFeedback(memberId); - verifyNoMoreInteractions(answerMapper); + verify(answerRepository).findAllWithQuestionAndFeedbackByMemberId(eq(memberId), any(Pageable.class)); + verifyNoMoreInteractions(answerRepository); } } \ No newline at end of file From b2440f078ba00a3b920afc78a4132080eed91a97 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 13 Oct 2025 18:09:16 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20Feedback=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/event/FeedbackEventListener.java | 4 +++- .../repository/FeedbackRepository.java | 12 ++++++++++++ .../service/impl/FeedbackServiceImpl.java | 14 ++++++++++---- .../event/FeedbackEventListenerTest.java | 10 +++++++--- .../service/impl/FeedbackServiceImplTest.java | 19 ++++++++++++++----- 5 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/question/repository/FeedbackRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java index 98646b9..090275b 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java +++ b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java @@ -6,6 +6,7 @@ import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; +import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.global.gpt.prompt.PromptFactory; import com.project.InsightPrep.global.gpt.service.GptResponseType; import com.project.InsightPrep.global.gpt.service.GptService; @@ -23,6 +24,7 @@ public class FeedbackEventListener { private final GptService gptService; private final FeedbackMapper feedbackMapper; + private final FeedbackRepository feedbackRepository; @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -43,7 +45,7 @@ public void handleAnswerSaved(AnswerSavedEvent event) { .improvement(gptResult.getImprovement()) .build(); - feedbackMapper.insertFeedback(feedback); + feedbackRepository.save(feedback); log.info("Feedback saved for Answer id = {}", answer.getId()); } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/repository/FeedbackRepository.java b/src/main/java/com/project/InsightPrep/domain/question/repository/FeedbackRepository.java new file mode 100644 index 0000000..2c4b28b --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/repository/FeedbackRepository.java @@ -0,0 +1,12 @@ +package com.project.InsightPrep.domain.question.repository; + +import com.project.InsightPrep.domain.question.entity.AnswerFeedback; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedbackRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"answer", "answer.question"}) + Optional findByAnswerId(Long answerId); +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java index 59ac5ed..6b36a0c 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java @@ -4,11 +4,15 @@ import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerFeedback; +import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; +import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; +import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.domain.question.service.FeedbackService; import com.project.InsightPrep.global.gpt.prompt.PromptFactory; import com.project.InsightPrep.global.gpt.service.GptResponseType; import com.project.InsightPrep.global.gpt.service.GptService; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -22,6 +26,7 @@ public class FeedbackServiceImpl implements FeedbackService { private final GptService gptService; private final FeedbackMapper feedbackMapper; + private final FeedbackRepository feedbackRepository; @Transactional @Async @@ -37,17 +42,18 @@ public void saveFeedback(Answer answer) { .improvement(gptResult.getImprovement()) .build(); - feedbackMapper.insertFeedback(feedback); + feedbackRepository.save(feedback); } @Override @Transactional(readOnly = true) public FeedbackDto getFeedback(long answerId) { - AnswerFeedback feedback = feedbackMapper.findById(answerId); - if (feedback == null) { + Optional optionalFeedback = feedbackRepository.findByAnswerId(answerId); + if (optionalFeedback.isEmpty()) { return null; } - System.out.println(feedback.getAnswer().getContent()); + AnswerFeedback feedback = optionalFeedback.get(); + return FeedbackDto.builder() .feedbackId(feedback.getId()) .questionId(feedback.getAnswer().getQuestion().getId()) diff --git a/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java b/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java index 7a5d5d0..3505ea6 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java @@ -7,6 +7,7 @@ import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; +import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.global.gpt.service.GptResponseType; import com.project.InsightPrep.global.gpt.service.GptService; import org.junit.jupiter.api.BeforeEach; @@ -30,6 +31,9 @@ class FeedbackEventListenerTest { @Mock private FeedbackMapper feedbackMapper; + @Mock + private FeedbackRepository feedbackRepository; + @InjectMocks private FeedbackEventListener feedbackEventListener; @@ -70,7 +74,7 @@ void handleAnswerSaved_success() { // then ArgumentCaptor captor = ArgumentCaptor.forClass(AnswerFeedback.class); - verify(feedbackMapper, times(1)).insertFeedback(captor.capture()); + verify(feedbackRepository, times(1)).save(captor.capture()); AnswerFeedback saved = captor.getValue(); assertThat(saved.getAnswer()).isEqualTo(answer); @@ -87,7 +91,7 @@ void handleAnswerSaved_nullAnswer() { // when & then assertThrows(QuestionException.class, () -> feedbackEventListener.handleAnswerSaved(event)); - verify(feedbackMapper, never()).insertFeedback(any()); + verify(feedbackRepository, never()).save(any()); } @Test @@ -105,6 +109,6 @@ void handleAnswerSaved_invalidAnswer() { assertThrows(QuestionException.class, () -> feedbackEventListener.handleAnswerSaved(event)); - verify(feedbackMapper, never()).insertFeedback(any()); + verify(feedbackRepository, never()).save(any()); } } \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java index a18a947..b7654e3 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java @@ -5,6 +5,8 @@ import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.FeedbackDto; @@ -12,8 +14,11 @@ import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.entity.Question; +import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; +import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.global.gpt.service.GptServiceImpl; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -34,6 +39,9 @@ class FeedbackServiceImplTest { @Mock private FeedbackMapper feedbackMapper; + @Mock + private FeedbackRepository feedbackRepository; + @Test @DisplayName("피드백 저장 - GPT 호출과 삽입 테스트") void saveFeedback_shouldCallGptAndInsertFeedback() { @@ -62,7 +70,7 @@ void saveFeedback_shouldCallGptAndInsertFeedback() { // then ArgumentCaptor captor = ArgumentCaptor.forClass(AnswerFeedback.class); - verify(feedbackMapper).insertFeedback(captor.capture()); + verify(feedbackRepository).save(captor.capture()); AnswerFeedback savedFeedback = captor.getValue(); assertEquals(4, savedFeedback.getScore()); @@ -94,7 +102,7 @@ void getFeedback_shouldReturnDto_whenFeedbackExists() { .modelAnswer("정답 예시...") .build(); - given(feedbackMapper.findById(2L)).willReturn(feedback); + given(feedbackRepository.findByAnswerId(2L)).willReturn(Optional.of(feedback)); // when FeedbackDto result = feedbackService.getFeedback(2L); @@ -113,12 +121,13 @@ void getFeedback_shouldReturnDto_whenFeedbackExists() { @DisplayName("피드백 조회 - 피드백 존재하지 않은 경우 null 리턴 테스트") void getFeedback_shouldReturnNull_whenFeedbackDoesNotExist() { // given - given(feedbackMapper.findById(99L)).willReturn(null); + Long answerId = 99L; + given(feedbackRepository.findByAnswerId(99L)).willReturn(Optional.empty()); // when - FeedbackDto result = feedbackService.getFeedback(99L); + var result = feedbackService.getFeedback(answerId); - // then assertNull(result); + then(feedbackRepository).should(times(1)).findByAnswerId(answerId); } } \ No newline at end of file From 27e70f5807a677b53fd9920618f5609c855a2d43 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 13 Oct 2025 22:20:11 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20RecentPromptFilter=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20mapper=EC=9D=84=20repository=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecentPromptFilterRepository.java | 24 +++++++ .../impl/RecentPromptFilterServiceImpl.java | 47 +++++++------ .../RecentPromptFilterServiceImplTest.java | 69 ++++++++++++------- 3 files changed, 96 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/question/repository/RecentPromptFilterRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/question/repository/RecentPromptFilterRepository.java b/src/main/java/com/project/InsightPrep/domain/question/repository/RecentPromptFilterRepository.java new file mode 100644 index 0000000..27cd45f --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/repository/RecentPromptFilterRepository.java @@ -0,0 +1,24 @@ +package com.project.InsightPrep.domain.question.repository; + +import com.project.InsightPrep.domain.question.entity.ItemType; +import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; +import jakarta.persistence.QueryHint; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.QueryHints; + +public interface RecentPromptFilterRepository extends JpaRepository { + + @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true")) + List findByMemberIdAndCategoryAndItemTypeOrderByCreatedAtDesc( + Long memberId, String category, ItemType itemType, Pageable pageable); + + /**중복 방지를 위해 이미 존재하는지 확인 (ON CONFLICT 대체용)*/ + boolean existsByMemberIdAndCategoryAndItemTypeAndItemValue( + Long memberId, + String category, + ItemType itemType, + String itemValue + ); +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java index f9b16fd..6256916 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java @@ -3,6 +3,7 @@ import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper; +import com.project.InsightPrep.domain.question.repository.RecentPromptFilterRepository; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import java.time.Duration; import java.util.ArrayList; @@ -11,6 +12,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +25,7 @@ public class RecentPromptFilterServiceImpl implements RecentPromptFilterService private final StringRedisTemplate redis; private final RecentPromptFilterMapper recentMapper; + private final RecentPromptFilterRepository recentPromptFilterRepository; private static final String KEY_FMT = "rp:%d:%s:%s"; // memberId, category, type public static final int MAX_SIZE = 10; public static final Duration TTL = Duration.ofDays(14); // 만료일 @@ -29,17 +33,16 @@ public class RecentPromptFilterServiceImpl implements RecentPromptFilterService @Override @Transactional public void record(long memberId, String category, ItemType type, String value) { - // DB 영구 저장 (unique 제약 조건으로 중복 방지) - RecentPromptFilter recentPromptFilter = RecentPromptFilter.builder() - .memberId(memberId) - .category(category) - .itemType(type) - .itemValue(value) - .build(); - try { - recentMapper.insert(recentPromptFilter); - } catch (DataIntegrityViolationException ignore) { - // 유니크 제약 충돌은 무시 (이미 기록된 값) + // DB 에 저장 (중복 시 무시) + boolean exists = recentPromptFilterRepository.existsByMemberIdAndCategoryAndItemTypeAndItemValue(memberId, category, type, value); + if (!exists) { + RecentPromptFilter recentPromptFilter = RecentPromptFilter.builder() + .memberId(memberId) + .category(category) + .itemType(type) + .itemValue(value) + .build(); + recentPromptFilterRepository.save(recentPromptFilter); } // redis 캐시 (최근 10개 유지) @@ -61,19 +64,23 @@ public void record(long memberId, String category, ItemType type, String value) public List getRecent(long memberId, String category, ItemType type, int limit) { String key = key(memberId, category, type); - // 최신순 상위 N - Set z = redis.opsForZSet().reverseRange(key, 0, Math.max(0, limit - 1)); - if (z != null && !z.isEmpty()) { - return new ArrayList<>(z); + // 1. 캐시 조회 - 최신순 상위 N + Set cached = redis.opsForZSet().reverseRange(key, 0, Math.max(0, limit - 1)); + if (cached != null && !cached.isEmpty()) { + return new ArrayList<>(cached); } - // 캐시 미스 → DB fallback (최근 10개) - List fromDb = recentMapper.findTopNByUserCategoryType(memberId, category, type, limit); - for (int i = 0; i < fromDb.size(); i++) { - redis.opsForZSet().add(key, fromDb.get(i), System.currentTimeMillis() + i); + // 2. 캐시 미스 → DB fallback (최근 10개) + Pageable pageable = PageRequest.of(0, limit); + List filters = recentPromptFilterRepository.findByMemberIdAndCategoryAndItemTypeOrderByCreatedAtDesc(memberId, category, type, pageable); + List values = filters.stream().map(RecentPromptFilter::getItemValue).toList(); + + // 3. 캐시 갱신 + for (int i = 0; i < values.size(); i++) { + redis.opsForZSet().add(key, values.get(i), System.currentTimeMillis() + i); } redis.expire(key, TTL); - return fromDb; + return values; } private String key(long userId, String category, ItemType type) { diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java index ada9376..bcaadae 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java @@ -8,6 +8,7 @@ import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper; +import com.project.InsightPrep.domain.question.repository.RecentPromptFilterRepository; import java.time.Duration; import java.util.*; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +19,7 @@ import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; @@ -26,6 +28,8 @@ class RecentPromptFilterServiceImplTest { @Mock StringRedisTemplate redis; @Mock RecentPromptFilterMapper recentMapper; + @Mock + RecentPromptFilterRepository recentPromptFilterRepository; @Mock ZSetOperations zset; @InjectMocks RecentPromptFilterServiceImpl service; @@ -49,17 +53,22 @@ class RecordTests { @Test @DisplayName("record() - 정상: DB insert + Redis ZSET add + TTL + 오래된 항목 trim") void record_ok() { - // given: DB insert 정상 - doNothing().when(recentMapper).insert(any(RecentPromptFilter.class)); + // given: 존재하지 않음 → save() 호출 예상 + when(recentPromptFilterRepository.existsByMemberIdAndCategoryAndItemTypeAndItemValue( + memberId, category, type, value + )).thenReturn(false); + // size가 15라고 가정 → MAX_SIZE(10) 초과 → 0..4 삭제 호출 when(zset.size(key)).thenReturn(15L); // when service.record(memberId, category, type, value); - // then: DB insert 호출 + // then: existsBy + save 호출 검증 + verify(recentPromptFilterRepository).existsByMemberIdAndCategoryAndItemTypeAndItemValue(memberId, category, type, value); ArgumentCaptor cap = ArgumentCaptor.forClass(RecentPromptFilter.class); - verify(recentMapper).insert(cap.capture()); + verify(recentPromptFilterRepository).save(cap.capture()); + RecentPromptFilter saved = cap.getValue(); assertThat(saved.getMemberId()).isEqualTo(memberId); assertThat(saved.getCategory()).isEqualTo(category); @@ -75,22 +84,25 @@ void record_ok() { } @Test - @DisplayName("record() - DB 유니크 충돌 시 예외 무시하고 Redis는 정상 갱신") - void record_duplicate_ignoreDbError() { - // given: unique 제약 충돌 유발 - doThrow(new DataIntegrityViolationException("dup")).when(recentMapper).insert(any(RecentPromptFilter.class)); - when(zset.size(key)).thenReturn(1L); // trim 안 일어나도록 + @DisplayName("record() - 이미 존재하는 경우 save() 호출 안함, Redis는 정상 갱신") + void record_alreadyExists_noSave() { + // given: 이미 존재 → existsBy == true → save() 안함 + when(recentPromptFilterRepository.existsByMemberIdAndCategoryAndItemTypeAndItemValue( + memberId, category, type, value + )).thenReturn(true); + + when(zset.size(key)).thenReturn(5L); // trim 없음 - // when/then - assertDoesNotThrow(() -> service.record(memberId, category, type, value)); + // when + service.record(memberId, category, type, value); - // DB insert 시도는 했지만, 예외는 흡수 - verify(recentMapper).insert(any(RecentPromptFilter.class)); + // then: DB 저장은 안됨 + verify(recentPromptFilterRepository).existsByMemberIdAndCategoryAndItemTypeAndItemValue(memberId, category, type, value); + verify(recentPromptFilterRepository, never()).save(any()); - // Redis는 정상 갱신 + // Redis는 정상 갱신됨 verify(zset).add(eq(key), eq(value), anyDouble()); verify(redis).expire(eq(key), eq(RecentPromptFilterServiceImpl.TTL)); - // size가 MAX 이하 → trim 안 함 verify(zset, never()).removeRange(anyString(), anyLong(), anyLong()); } } @@ -99,7 +111,7 @@ void record_duplicate_ignoreDbError() { class GetRecentTests { @Test - @DisplayName("getRecent() - 캐시 HIT: Redis ZSET에서 최신 N개 반환, DAO 호출 안 함") + @DisplayName("getRecent() - 캐시 HIT: Redis ZSET에서 최신 N개 반환, Repository 호출 안 함") void getRecent_cacheHit() { // given Set cached = new LinkedHashSet<>(List.of("a", "b", "c")); @@ -110,7 +122,8 @@ void getRecent_cacheHit() { // then assertThat(res).containsExactly("a", "b", "c"); - verify(recentMapper, never()).findTopNByUserCategoryType(anyLong(), anyString(), any(), anyInt()); + verify(recentPromptFilterRepository, never()).findByMemberIdAndCategoryAndItemTypeOrderByCreatedAtDesc(anyLong(), anyString(), any(), any( + Pageable.class)); verify(redis, never()).expire(anyString(), any(Duration.class)); // 캐시 HIT 시 expire 갱신 안 함(구현 그대로 검증) } @@ -120,21 +133,29 @@ void getRecent_cacheMiss_dbFallbackAndWarmup() { // given: 캐시 미스 when(zset.reverseRange(key, 0, 9)).thenReturn(Collections.emptySet()); - List fromDb = List.of("t1", "t2", "t3"); - when(recentMapper.findTopNByUserCategoryType(memberId, category, type, 10)) - .thenReturn(fromDb); + // DB에서 반환될 mock 엔티티 리스트 + List fromDbEntities = List.of( + RecentPromptFilter.builder().itemValue("t1").build(), + RecentPromptFilter.builder().itemValue("t2").build(), + RecentPromptFilter.builder().itemValue("t3").build() + ); + + when(recentPromptFilterRepository.findByMemberIdAndCategoryAndItemTypeOrderByCreatedAtDesc(eq(memberId), eq(category), eq(type), any(Pageable.class))) + .thenReturn(fromDbEntities); // when List res = service.getRecent(memberId, category, type, 10); // then - assertThat(res).containsExactlyElementsOf(fromDb); + assertThat(res).containsExactly("t1", "t2", "t3"); - // DB 호출 - verify(recentMapper).findTopNByUserCategoryType(memberId, category, type, 10); + // DB 호출 검증 + verify(recentPromptFilterRepository).findByMemberIdAndCategoryAndItemTypeOrderByCreatedAtDesc( + eq(memberId), eq(category), eq(type), any(Pageable.class) + ); // Redis warm-up: add 3번 + expire - verify(zset, times(fromDb.size())).add(eq(key), anyString(), anyDouble()); + verify(zset, times(fromDbEntities.size())).add(eq(key), anyString(), anyDouble()); verify(redis).expire(eq(key), eq(RecentPromptFilterServiceImpl.TTL)); } } From d9239ed8f0515318204d96a639ba563db77c7b24 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Tue, 14 Oct 2025 01:55:50 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20SharedPost=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/MemberRepository.java | 7 + .../domain/post/entity/SharedPost.java | 9 + .../post/reqository/CommentRepository.java | 8 + .../post/reqository/SharedPostRepository.java | 14 + .../post/service/impl/CommentServiceImpl.java | 16 +- .../service/impl/SharedPostServiceImpl.java | 103 +++++-- .../service/impl/CommentServiceImplTest.java | 59 ++-- .../impl/SharedPostServiceImplTest.java | 264 +++++++++++++----- 8 files changed, 345 insertions(+), 135 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/member/repository/MemberRepository.java create mode 100644 src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java create mode 100644 src/main/java/com/project/InsightPrep/domain/post/reqository/SharedPostRepository.java diff --git a/src/main/java/com/project/InsightPrep/domain/member/repository/MemberRepository.java b/src/main/java/com/project/InsightPrep/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..f0584ef --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/member/repository/MemberRepository.java @@ -0,0 +1,7 @@ +package com.project.InsightPrep.domain.member.repository; + +import com.project.InsightPrep.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/entity/SharedPost.java b/src/main/java/com/project/InsightPrep/domain/post/entity/SharedPost.java index a606323..4e5035d 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/entity/SharedPost.java +++ b/src/main/java/com/project/InsightPrep/domain/post/entity/SharedPost.java @@ -1,6 +1,8 @@ package com.project.InsightPrep.domain.post.entity; import com.project.InsightPrep.domain.member.entity.Member; +import com.project.InsightPrep.domain.post.exception.PostErrorCode; +import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.global.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -53,4 +55,11 @@ public class SharedPost extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) private PostStatus status = PostStatus.OPEN; + + public void markResolved() { + if (this.status == PostStatus.RESOLVED) { + throw new PostException(PostErrorCode.ALREADY_RESOLVED); + } + this.status = PostStatus.RESOLVED; + } } diff --git a/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java b/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java new file mode 100644 index 0000000..b79a1d4 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java @@ -0,0 +1,8 @@ +package com.project.InsightPrep.domain.post.reqository; + +import com.project.InsightPrep.domain.post.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + long countBySharedPostId(Long postId); +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/reqository/SharedPostRepository.java b/src/main/java/com/project/InsightPrep/domain/post/reqository/SharedPostRepository.java new file mode 100644 index 0000000..10da7b9 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/reqository/SharedPostRepository.java @@ -0,0 +1,14 @@ +package com.project.InsightPrep.domain.post.reqository; + +import com.project.InsightPrep.domain.post.entity.SharedPost; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SharedPostRepository extends JpaRepository { + + @EntityGraph(attributePaths = { + "member", "answer", "answer.question", "answer.feedback" + }) + Optional findById(Long id); // findPostDetailById +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java index 6496eb4..65c2675 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java @@ -12,6 +12,7 @@ import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.post.mapper.CommentMapper; import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.post.service.CommentService; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -29,6 +30,7 @@ public class CommentServiceImpl implements CommentService { private final SecurityUtil securityUtil; private final SharedPostMapper sharedPostMapper; + private final SharedPostRepository sharedPostRepository; private final CommentMapper commentMapper; @Override @@ -36,10 +38,7 @@ public class CommentServiceImpl implements CommentService { public CommentRes createComment(long postId, CreateDto req) { Member me = securityUtil.getAuthenticatedMember(); - SharedPost post = sharedPostMapper.findById(postId); - if (post == null) { - throw new PostException(PostErrorCode.POST_NOT_FOUND); - } + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); Comment comment = Comment.builder() .content(req.getContent()) @@ -62,8 +61,7 @@ public CommentRes createComment(long postId, CreateDto req) { @Override @Transactional public void updateComment(long postId, long commentId, UpdateDto req) { - SharedPost post = sharedPostMapper.findById(postId); - if (post == null) throw new PostException(PostErrorCode.POST_NOT_FOUND); + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); CommentRow comment = commentMapper.findRowById(commentId); if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); @@ -84,8 +82,7 @@ public void updateComment(long postId, long commentId, UpdateDto req) { @Override @Transactional public void deleteComment(long postId, long commentId) { - SharedPost post = sharedPostMapper.findById(postId); - if (post == null) throw new PostException(PostErrorCode.POST_NOT_FOUND); + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); CommentRow comment = commentMapper.findRowById(commentId); if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); @@ -102,8 +99,7 @@ public void deleteComment(long postId, long commentId) { @Override @Transactional(readOnly = true) public PageResponse getComments(long postId, int page, int size) { - SharedPost post = sharedPostMapper.findById(postId); - if (post == null) throw new PostException(PostErrorCode.POST_NOT_FOUND); + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 50); diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java index b113322..91d9bde 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java @@ -1,21 +1,32 @@ package com.project.InsightPrep.domain.post.service.impl; +import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.post.dto.PostRequest.Create; import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; import com.project.InsightPrep.domain.post.entity.PostStatus; +import com.project.InsightPrep.domain.post.entity.SharedPost; import com.project.InsightPrep.domain.post.exception.PostErrorCode; import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.reqository.CommentRepository; +import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.post.service.SharedPostService; import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.domain.question.entity.Answer; +import com.project.InsightPrep.domain.question.entity.AnswerFeedback; +import com.project.InsightPrep.domain.question.entity.Question; +import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,36 +37,67 @@ public class SharedPostServiceImpl implements SharedPostService { private final SecurityUtil securityUtil; private final SharedPostMapper sharedPostMapper; + private final SharedPostRepository sharedPostRepository; private final AnswerMapper answerMapper; private final AnswerRepository answerRepository; + private final CommentRepository commentRepository; @Override @Transactional public Long createPost(Create req) { - long memberId = securityUtil.getLoginMemberId(); + Member member = securityUtil.getAuthenticatedMember(); + long memberId = member.getId(); boolean myAnswer = answerRepository.existsByIdAndMemberId(req.getAnswerId(), memberId); if (!myAnswer) { throw new PostException(PostErrorCode.FORBIDDEN_OR_NOT_FOUND_ANSWER); } - int n = sharedPostMapper.insertSharedPost(req.getTitle(), req.getContent(), req.getAnswerId(), memberId, PostStatus.OPEN.name()); - if (n != 1) { - throw new PostException(PostErrorCode.CREATE_FAILED); - } + Answer answer = answerRepository.findById(req.getAnswerId()).orElseThrow(() -> new PostException(QuestionErrorCode.ANSWER_NOT_FOUND)); + + SharedPost sharedPost = SharedPost.builder() + .title(req.getTitle()) + .content(req.getContent()) + .answer(answer) + .member(member) + .status(PostStatus.OPEN) + .build(); - return sharedPostMapper.lastInsertedId(); + return sharedPostRepository.save(sharedPost).getId(); } @Override @Transactional(readOnly = true) public PostDetailDto getPostDetail(long postId) { long viewerId = securityUtil.getLoginMemberId(); - PostDetailDto dto = sharedPostMapper.findPostDetailById(postId, viewerId); - if (dto == null) { - throw new PostException(PostErrorCode.POST_NOT_FOUND); - } - return dto; + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); + + Answer answer = post.getAnswer(); + Question question = answer.getQuestion(); + AnswerFeedback feedback = answer.getFeedback(); + Member author = post.getMember(); + + long commentCount = commentRepository.countBySharedPostId(postId); + return PostDetailDto.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .status(post.getStatus().name()) + .createdAt(post.getCreatedAt()) + .authorId(author.getId()) + .authorNickname(author.getNickname()) + .questionId(question.getId()) + .category(question.getCategory()) + .question(question.getContent()) + .answerId(answer.getId()) + .answer(answer.getContent()) + .feedbackId(feedback != null ? feedback.getId() : null) + .score(feedback != null ? feedback.getScore() : null) + .improvement(feedback != null ? feedback.getImprovement() : null) + .modelAnswer(feedback != null ? feedback.getModelAnswer() : null) + .myPost(author.getId().equals(viewerId)) + .commentCount(commentCount) + .build(); } @Override @@ -63,21 +105,19 @@ public PostDetailDto getPostDetail(long postId) { public void resolve(long postId) { long loginId = securityUtil.getLoginMemberId(); - PostOwnerStatusDto row = sharedPostMapper.findOwnerAndStatus(postId); - if (row == null) { - throw new PostException(PostErrorCode.POST_NOT_FOUND); - } - if (!row.getMemberId().equals(loginId)) { + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); + + Long ownerId = post.getMember().getId(); + PostStatus status = post.getStatus(); + + if (!ownerId.equals(loginId)) { throw new PostException(PostErrorCode.FORBIDDEN); } - if ("RESOLVED".equals(row.getStatus())) { + if (status == PostStatus.RESOLVED) { throw new PostException(PostErrorCode.ALREADY_RESOLVED); } - int updated = sharedPostMapper.updateStatusToResolved(postId); - if (updated != 1) { - throw new PostException(PostErrorCode.CONFLICT); - } + post.markResolved(); } @Override @@ -85,10 +125,25 @@ public void resolve(long postId) { public PageResponse getPosts(int page, int size) { int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 50); - int offset = (safePage - 1) * safeSize; - List content = sharedPostMapper.findSharedPostsPaged(safeSize, offset); - long total = sharedPostMapper.countSharedPosts(); + // PageRequest 객체를 통해 limit, offset, 정렬까지 모두 처리됨 + PageRequest pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "id")); + + // JPA가 자동으로 페이징 쿼리 실행 + count 쿼리도 자동 수행 + Page postsPage = sharedPostRepository.findAll(pageable); + + List content = postsPage.getContent().stream() + .map(sp -> PostListItemDto.builder() + .postId(sp.getId()) + .title(sp.getTitle()) + .createdAt(sp.getCreatedAt()) + .status(sp.getStatus().name()) + .question(sp.getAnswer().getQuestion().getContent()) + .category(sp.getAnswer().getQuestion().getCategory()) + .build()) + .toList(); + + long total = postsPage.getTotalElements(); return PageResponse.of(content, safePage, safeSize, total); } diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java index b0356b7..bdf112e 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java @@ -23,10 +23,12 @@ import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.post.mapper.CommentMapper; import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -46,6 +48,9 @@ class CommentServiceImplTest { @Mock SharedPostMapper sharedPostMapper; + @Mock + SharedPostRepository sharedPostRepository; + @Mock CommentMapper commentMapper; @@ -80,7 +85,7 @@ void create_success() { SharedPost post = SharedPost.builder().id(postId).build(); when(securityUtil.getAuthenticatedMember()).thenReturn(me); - when(sharedPostMapper.findById(postId)).thenReturn(post); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(post)); // insert 시 selectKey로 id가 세팅되는 것을 흉내 doAnswer(inv -> { Comment c = inv.getArgument(0); @@ -103,7 +108,7 @@ void create_success() { assertThat(res.getPostId()).isEqualTo(postId); verify(securityUtil).getAuthenticatedMember(); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).insertComment(any(Comment.class)); } @@ -111,14 +116,14 @@ void create_success() { @DisplayName("실패 - 게시글 없음 → POST_NOT_FOUND") void create_post_not_found() { long postId = 99L; - when(sharedPostMapper.findById(postId)).thenReturn(null); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.createComment(postId, new CreateDto("x")) ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verifyNoInteractions(commentMapper); } } @@ -135,7 +140,7 @@ void update_success() { long commentId = 200L; long me = 1L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, postId, me, "old")); when(securityUtil.getLoginMemberId()).thenReturn(me); @@ -143,7 +148,7 @@ void update_success() { commentService.updateComment(postId, commentId, new UpdateDto("new content")); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verify(securityUtil).getLoginMemberId(); verify(commentMapper).updateContent(commentId, me, "new content"); @@ -154,14 +159,14 @@ void update_success() { void update_post_not_found() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(null); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.updateComment(postId, commentId, new UpdateDto("x")) ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verifyNoMoreInteractions(sharedPostMapper); verifyNoInteractions(commentMapper, securityUtil); } @@ -171,7 +176,7 @@ void update_post_not_found() { void update_comment_not_found() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)).thenReturn(null); assertThatThrownBy(() -> @@ -179,7 +184,7 @@ void update_comment_not_found() { ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verifyNoMoreInteractions(commentMapper); verifyNoInteractions(securityUtil); @@ -190,7 +195,7 @@ void update_comment_not_found() { void update_wrong_postId() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); // 댓글이 다른 postId에 속함 when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, 999L, 1L, "x")); @@ -200,7 +205,7 @@ void update_wrong_postId() { ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verifyNoMoreInteractions(commentMapper); verifyNoInteractions(securityUtil); @@ -214,7 +219,7 @@ void update_forbidden() { long owner = 1L; long me = 2L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, postId, owner, "x")); when(securityUtil.getLoginMemberId()).thenReturn(me); @@ -224,7 +229,7 @@ void update_forbidden() { ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verify(securityUtil).getLoginMemberId(); verifyNoMoreInteractions(commentMapper); @@ -243,7 +248,7 @@ void delete_success() { long commentId = 200L; long me = 1L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, postId, me, "x")); when(securityUtil.getLoginMemberId()).thenReturn(me); @@ -251,7 +256,7 @@ void delete_success() { commentService.deleteComment(postId, commentId); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verify(securityUtil).getLoginMemberId(); verify(commentMapper).deleteByIdAndMember(commentId, me); @@ -263,13 +268,13 @@ void delete_post_not_found() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(null); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verifyNoInteractions(commentMapper, securityUtil); } @@ -279,14 +284,14 @@ void delete_comment_not_found() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)).thenReturn(null); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verifyNoMoreInteractions(commentMapper); verifyNoInteractions(securityUtil); @@ -298,7 +303,7 @@ void delete_wrong_postId() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, 999L, 1L, "x")); @@ -306,7 +311,7 @@ void delete_wrong_postId() { .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verifyNoMoreInteractions(commentMapper); verifyNoInteractions(securityUtil); @@ -320,7 +325,7 @@ void delete_forbidden() { long owner = 1L; long me = 2L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, postId, owner, "x")); when(securityUtil.getLoginMemberId()).thenReturn(me); @@ -329,7 +334,7 @@ void delete_forbidden() { .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verify(commentMapper).findRowById(commentId); verify(securityUtil).getLoginMemberId(); verifyNoMoreInteractions(commentMapper); @@ -344,13 +349,13 @@ class ExceptionCases { @DisplayName("게시글이 없으면 POST_NOT_FOUND 발생") void post_not_found() { long postId = 999L; - when(sharedPostMapper.findById(postId)).thenReturn(null); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.getComments(postId, 1, 10)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); + verify(sharedPostRepository).findById(postId); verifyNoMoreInteractions(commentMapper, securityUtil); } } @@ -362,7 +367,7 @@ class SuccessCases { @BeforeEach void setUp() { // 공통: 게시글 존재 - when(sharedPostMapper.findById(1L)).thenReturn(stubPost(1L)); + when(sharedPostRepository.findById(1L)).thenReturn(Optional.of(stubPost(1L))); } @Test diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java index 2e0c81f..eb737e1 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -10,26 +11,38 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.project.InsightPrep.domain.member.entity.Member; +import com.project.InsightPrep.domain.member.repository.MemberRepository; import com.project.InsightPrep.domain.post.dto.PostRequest.Create; import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; import com.project.InsightPrep.domain.post.entity.PostStatus; +import com.project.InsightPrep.domain.post.entity.SharedPost; import com.project.InsightPrep.domain.post.exception.PostErrorCode; import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.reqository.CommentRepository; +import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.domain.question.entity.Answer; +import com.project.InsightPrep.domain.question.entity.Question; +import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class SharedPostServiceImplTest { @@ -40,12 +53,21 @@ class SharedPostServiceImplTest { @Mock SharedPostMapper sharedPostMapper; + @Mock + SharedPostRepository sharedPostRepository; + @Mock AnswerMapper answerMapper; @Mock AnswerRepository answerRepository; + @Mock + MemberRepository memberRepository; + + @Mock + CommentRepository commentRepository; + @InjectMocks SharedPostServiceImpl service; @@ -55,67 +77,89 @@ void createPost_success() { long memberId = 10L; long answerId = 111L; + Member member = Member.builder().id(memberId).build(); + Answer answer = Answer.builder().id(answerId).build(); Create req = Create.builder() .title("t") .content("c") .answerId(answerId) .build(); - given(securityUtil.getLoginMemberId()).willReturn(memberId); + + SharedPost savedPost = SharedPost.builder() + .id(999L) + .title("t") + .content("c") + .answer(answer) + .member(member) + .status(PostStatus.OPEN) + .build(); + + // mock 설정 + given(securityUtil.getAuthenticatedMember()).willReturn(member); given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(true); - given(sharedPostMapper.insertSharedPost(eq("t"), eq("c"), eq(answerId), eq(memberId), eq(PostStatus.OPEN.name()))) - .willReturn(1); - given(sharedPostMapper.lastInsertedId()).willReturn(999L); + given(answerRepository.findById(answerId)).willReturn(Optional.of(answer)); + given(sharedPostRepository.save(any(SharedPost.class))).willReturn(savedPost); + // when Long id = service.createPost(req); + // then assertThat(id).isEqualTo(999L); - verify(sharedPostMapper).insertSharedPost("t", "c", answerId, memberId, PostStatus.OPEN.name()); - verify(sharedPostMapper).lastInsertedId(); + verify(sharedPostRepository).save(any(SharedPost.class)); + verify(answerRepository).existsByIdAndMemberId(answerId, memberId); + verify(answerRepository).findById(answerId); } @Test @DisplayName("createPost: 내 답변이 아니면 FORBIDDEN_OR_NOT_FOUND_ANSWER") void createPost_forbiddenOrNotFoundAnswer() { + // given long memberId = 10L; long answerId = 111L; + Member member = Member.builder().id(memberId).build(); Create req = Create.builder() .title("t") .content("c") .answerId(answerId) .build(); - given(securityUtil.getLoginMemberId()).willReturn(memberId); + given(securityUtil.getAuthenticatedMember()).willReturn(member); given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(false); + // when & then assertThatThrownBy(() -> service.createPost(req)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.FORBIDDEN_OR_NOT_FOUND_ANSWER.getMessage()); - verify(sharedPostMapper, never()).insertSharedPost(anyString(), anyString(), anyLong(), anyLong(), anyString()); + verify(sharedPostRepository, never()).save(any(SharedPost.class)); } @Test @DisplayName("createPost: insert 실패 시 CREATE_FAILED") void createPost_createFailed() { + // given long memberId = 10L; long answerId = 111L; + Member member = Member.builder().id(memberId).build(); Create req = Create.builder() .title("t") .content("c") .answerId(answerId) .build(); - given(securityUtil.getLoginMemberId()).willReturn(memberId); + given(securityUtil.getAuthenticatedMember()).willReturn(member); given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(true); - given(sharedPostMapper.insertSharedPost(anyString(), anyString(), anyLong(), anyLong(), anyString())) - .willReturn(0); + given(answerRepository.findById(answerId)).willReturn(Optional.empty()); + // when & then assertThatThrownBy(() -> service.createPost(req)) .isInstanceOf(PostException.class) - .hasMessageContaining(PostErrorCode.CREATE_FAILED.getMessage()); + .hasMessageContaining(QuestionErrorCode.ANSWER_NOT_FOUND.getMessage()); + + verify(sharedPostRepository, never()).save(any(SharedPost.class)); } @Test @@ -124,33 +168,52 @@ void getPostDetail_success() { long postId = 7L; long viewerId = 10L; - PostDetailDto dto = PostDetailDto.builder() - .postId(postId) + // Mock member(작성자) + Member author = Member.builder() + .id(viewerId) + .nickname("me") + .build(); + + // Mock question, answer, feedback + Question question = Question.builder() + .id(1L) + .category("CS") + .content("Q?") + .build(); + + Answer answer = Answer.builder() + .id(111L) + .content("A") + .question(question) + .build(); + + SharedPost post = SharedPost.builder() + .id(postId) .title("t") .content("c") - .status(PostStatus.OPEN.name()) - .createdAt(LocalDateTime.now()) - .authorId(10L) - .authorNickname("me") - .questionId(1L) - .category("CS") - .question("Q?") - .answerId(111L) - .answer("A") - .feedbackId(null) - .score(null) - .improvement(null) - .modelAnswer(null) - // 필요 시 서비스에서 채워지는 필드가 있다면 거기에 맞춰 준다 + .status(PostStatus.OPEN) + .member(author) + .answer(answer) .build(); + // db Mock given(securityUtil.getLoginMemberId()).willReturn(viewerId); - given(sharedPostMapper.findPostDetailById(postId, viewerId)).willReturn(dto); + given(sharedPostRepository.findById(postId)).willReturn(Optional.of(post)); + given(commentRepository.countBySharedPostId(postId)).willReturn(5L); + // when PostDetailDto res = service.getPostDetail(postId); + // then + assertThat(res).isNotNull(); assertThat(res.getPostId()).isEqualTo(postId); - verify(sharedPostMapper).findPostDetailById(postId, viewerId); + assertThat(res.getTitle()).isEqualTo("t"); + assertThat(res.getAuthorNickname()).isEqualTo("me"); + assertThat(res.getMyPost()).isTrue(); + assertThat(res.getCommentCount()).isEqualTo(5L); + + verify(sharedPostRepository).findById(postId); + verify(commentRepository).countBySharedPostId(postId); } @Test @@ -160,61 +223,84 @@ void getPostDetail_notFound() { long viewerId = 10L; given(securityUtil.getLoginMemberId()).willReturn(viewerId); - given(sharedPostMapper.findPostDetailById(postId, viewerId)).willReturn(null); + given(sharedPostRepository.findById(postId)).willReturn(Optional.empty()); + // when & then assertThatThrownBy(() -> service.getPostDetail(postId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + + verify(sharedPostRepository).findById(postId); + verify(commentRepository, never()).countBySharedPostId(anyLong()); } @Test - @DisplayName("resolve: 성공") + @DisplayName("resolve: 성공적으로 상태를 RESOLVED로 변경") void resolve_success() { + // given long postId = 5L; long loginId = 10L; - PostOwnerStatusDto row = PostOwnerStatusDto.builder() - .memberId(loginId) - .status("OPEN") + Member owner = Member.builder() + .id(loginId) + .build(); + + SharedPost post = SharedPost.builder() + .id(postId) + .member(owner) + .status(PostStatus.OPEN) .build(); given(securityUtil.getLoginMemberId()).willReturn(loginId); - given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); - given(sharedPostMapper.updateStatusToResolved(postId)).willReturn(1); + given(sharedPostRepository.findById(postId)).willReturn(Optional.of(post)); + // when service.resolve(postId); - verify(sharedPostMapper).updateStatusToResolved(postId); + // then + assertThat(post.getStatus()).isEqualTo(PostStatus.RESOLVED); + verify(sharedPostRepository).findById(postId); } @Test @DisplayName("resolve: 게시글 없음 → POST_NOT_FOUND") void resolve_notFound() { + // given long postId = 5L; long loginId = 10L; given(securityUtil.getLoginMemberId()).willReturn(loginId); - given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(null); + given(sharedPostRepository.findById(postId)).willReturn(Optional.empty()); + // when & then assertThatThrownBy(() -> service.resolve(postId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + + verify(sharedPostRepository).findById(postId); } @Test @DisplayName("resolve: 본인 글 아님 → FORBIDDEN") void resolve_forbidden() { + // given long postId = 5L; - long loginId = 10L; + long loginId = 10L; // 다른 사용자 + + Member owner = Member.builder() + .id(999L) + .build(); - PostOwnerStatusDto row = PostOwnerStatusDto.builder() - .memberId(999L) // 다른 사람 - .status("OPEN") + SharedPost post = SharedPost.builder() + .id(postId) + .member(owner) + .status(PostStatus.OPEN) .build(); given(securityUtil.getLoginMemberId()).willReturn(loginId); - given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); + given(sharedPostRepository.findById(postId)).willReturn(Optional.of(post)); + // when & then assertThatThrownBy(() -> service.resolve(postId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.FORBIDDEN.getMessage()); @@ -223,75 +309,105 @@ void resolve_forbidden() { @Test @DisplayName("resolve: 이미 RESOLVED → ALREADY_RESOLVED") void resolve_alreadyResolved() { + // given long postId = 5L; long loginId = 10L; - PostOwnerStatusDto row = PostOwnerStatusDto.builder() - .memberId(loginId) - .status("RESOLVED") + Member owner = Member.builder() + .id(loginId) + .build(); + + SharedPost post = SharedPost.builder() + .id(postId) + .member(owner) + .status(PostStatus.RESOLVED) .build(); given(securityUtil.getLoginMemberId()).willReturn(loginId); - given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); + given(sharedPostRepository.findById(postId)).willReturn(Optional.of(post)); + // when & then assertThatThrownBy(() -> service.resolve(postId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.ALREADY_RESOLVED.getMessage()); } @Test - @DisplayName("resolve: 업데이트 실패 → CONFLICT") + @DisplayName("resolve: markResolved() 내부 예외 발생 → IllegalStateException") void resolve_conflict() { + // given long postId = 5L; long loginId = 10L; - PostOwnerStatusDto row = PostOwnerStatusDto.builder() - .memberId(loginId) - .status("OPEN") + Member owner = Member.builder() + .id(loginId) + .build(); + + // markResolved() 안에서 IllegalStateException 터지도록 미리 RESOLVED 상태 + SharedPost post = SharedPost.builder() + .id(postId) + .member(owner) + .status(PostStatus.RESOLVED) .build(); given(securityUtil.getLoginMemberId()).willReturn(loginId); - given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); - given(sharedPostMapper.updateStatusToResolved(postId)).willReturn(0); + given(sharedPostRepository.findById(postId)).willReturn(Optional.of(post)); + // when & then assertThatThrownBy(() -> service.resolve(postId)) - .isInstanceOf(PostException.class) - .hasMessageContaining(PostErrorCode.CONFLICT.getMessage()); + .isInstanceOf(PostException.class) // 서비스가 PostException으로 래핑했는지 확인 + .hasMessageContaining(PostErrorCode.ALREADY_RESOLVED.getMessage()); } @Test @DisplayName("getPosts: 정상 페이징(보정 포함)과 결과 매핑") void getPosts_success() { + // given // page=0, size=1000 들어와도 보정: page=1, size=50 int reqPage = 0; int reqSize = 1000; int safePage = 1; int safeSize = 50; - int offset = 0; - - List rows = List.of( - PostListItemDto.builder() - .postId(1L) - .title("T1") - .status("OPEN") - .createdAt(LocalDateTime.now()) - .question("Q1") - .category("CS") - .build() - ); - - given(sharedPostMapper.findSharedPostsPaged(safeSize, offset)).willReturn(rows); - given(sharedPostMapper.countSharedPosts()).willReturn(1L); + // Mock 데이터 구성 + Question question = Question.builder() + .id(1L) + .category("CS") + .content("Q1") + .build(); + + Answer answer = Answer.builder() + .id(11L) + .content("A1") + .question(question) + .build(); + + SharedPost post = SharedPost.builder() + .id(1L) + .title("T1") + .content("C1") + .status(PostStatus.OPEN) + .answer(answer) + .member(Member.builder().id(42L).nickname("홍길동").build()) + .build(); + + List posts = List.of(post); + Page pageResult = new PageImpl<>(posts); + + given(sharedPostRepository.findAll(any(Pageable.class))).willReturn(pageResult); + + // when PageResponse res = service.getPosts(reqPage, reqSize); + // then assertThat(res.getPage()).isEqualTo(safePage); assertThat(res.getSize()).isEqualTo(safeSize); - assertThat(res.getTotalElements()).isEqualTo(1L); + assertThat(res.getTotalElements()).isEqualTo(1); assertThat(res.getContent()).hasSize(1); assertThat(res.getContent().get(0).getTitle()).isEqualTo("T1"); + assertThat(res.getContent().get(0).getCategory()).isEqualTo("CS"); + assertThat(res.getContent().get(0).getQuestion()).isEqualTo("Q1"); - verify(sharedPostMapper).findSharedPostsPaged(safeSize, offset); - verify(sharedPostMapper).countSharedPosts(); + verify(sharedPostRepository).findAll(any(Pageable.class)); } } \ No newline at end of file From 177a3d55ecb359c9f39719ed3bea641b1a45610e Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Tue, 14 Oct 2025 03:15:33 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20Comment=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20mapper=EC=9D=84=20repository=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/entity/Comment.java | 4 + .../post/reqository/CommentRepository.java | 6 + .../post/service/impl/CommentServiceImpl.java | 58 ++--- .../service/impl/CommentServiceImplTest.java | 240 +++++++++++------- 4 files changed, 186 insertions(+), 122 deletions(-) diff --git a/src/main/java/com/project/InsightPrep/domain/post/entity/Comment.java b/src/main/java/com/project/InsightPrep/domain/post/entity/Comment.java index 66bbf04..211bf05 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/entity/Comment.java +++ b/src/main/java/com/project/InsightPrep/domain/post/entity/Comment.java @@ -42,4 +42,8 @@ public class Comment extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", nullable = false) private SharedPost sharedPost; + + public void updateContent(String newContent) { + this.content = newContent; + } } diff --git a/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java b/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java index b79a1d4..58ec799 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java +++ b/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java @@ -1,8 +1,14 @@ package com.project.InsightPrep.domain.post.reqository; import com.project.InsightPrep.domain.post.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface CommentRepository extends JpaRepository { long countBySharedPostId(Long postId); + + Page findBySharedPost_IdOrderByCreatedAtAscIdAsc(Long postId, Pageable pageable); + + long countBySharedPost_Id(Long postId); } diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java index 65c2675..df3a6c2 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java @@ -12,6 +12,7 @@ import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.post.mapper.CommentMapper; import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.reqository.CommentRepository; import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.post.service.CommentService; import com.project.InsightPrep.domain.question.dto.response.PageResponse; @@ -20,6 +21,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +37,7 @@ public class CommentServiceImpl implements CommentService { private final SharedPostMapper sharedPostMapper; private final SharedPostRepository sharedPostRepository; private final CommentMapper commentMapper; + private final CommentRepository commentRepository; @Override @Transactional @@ -46,7 +52,7 @@ public CommentRes createComment(long postId, CreateDto req) { .sharedPost(post) .build(); - commentMapper.insertComment(comment); + commentRepository.save(comment); return CommentRes.builder() .commentId(comment.getId()) @@ -54,7 +60,7 @@ public CommentRes createComment(long postId, CreateDto req) { .authorId(me.getId()) .authorNickname(me.getNickname()) .postId(postId) - .createdAt(LocalDateTime.now()) + .createdAt(comment.getCreatedAt()) .build(); } @@ -62,38 +68,32 @@ public CommentRes createComment(long postId, CreateDto req) { @Transactional public void updateComment(long postId, long commentId, UpdateDto req) { SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); - - CommentRow comment = commentMapper.findRowById(commentId); - if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new PostException(PostErrorCode.COMMENT_NOT_FOUND)); // postId 매칭 검증 - if (comment.getPostId() != postId) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + if (!comment.getSharedPost().getId().equals(postId)) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); // 본인이 작성한 댓글 검증 long me = securityUtil.getLoginMemberId(); - if (comment.getMemberId() != me) throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); + if (!comment.getMember().getId().equals(me)) throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); - int n = commentMapper.updateContent(commentId, me, req.getContent()); - if (n == 0) { - throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); - } + comment.updateContent(req.getContent()); } @Override @Transactional public void deleteComment(long postId, long commentId) { SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); + Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new PostException(PostErrorCode.COMMENT_NOT_FOUND)); - CommentRow comment = commentMapper.findRowById(commentId); - if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); - if (comment.getPostId() != postId) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + // postId 매칭 검증 + if (!comment.getSharedPost().getId().equals(postId)) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + // 본인 작성자 검증 long me = securityUtil.getLoginMemberId(); - if (comment.getMemberId() != me) throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); - int n = commentMapper.deleteByIdAndMember(commentId, me); - if (n == 0) { - throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); - } + if (!comment.getMember().getId().equals(me)) throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); + + commentRepository.delete(comment); } @Override @@ -101,27 +101,27 @@ public void deleteComment(long postId, long commentId) { public PageResponse getComments(long postId, int page, int size) { SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND)); - int safePage = Math.max(page, 1); + int safePage = Math.max(page, 1) - 1; // Pageable은 0-based int safeSize = Math.min(Math.max(size, 1), 50); - int offset = (safePage - 1) * safeSize; + Pageable pageable = PageRequest.of(safePage, safeSize, Sort.by(Sort.Order.asc("createdAt"), Sort.Order.asc("id"))); - List raw = commentMapper.findByPostPaged(postId, safeSize, offset); - long total = commentMapper.countByPost(postId); + Page pageResult = commentRepository.findBySharedPost_IdOrderByCreatedAtAscIdAsc(postId, pageable); + long total = commentRepository.countBySharedPost_Id(postId); // 현재 로그인한 사용자 id long me = securityUtil.getLoginMemberId(); - List content = raw.stream() + List content = pageResult.getContent().stream() .map(c -> CommentListItem.builder() - .commentId(c.getCommentId()) - .authorId(c.getAuthorId()) - .authorNickname(c.getAuthorNickname()) + .commentId(c.getId()) + .authorId(c.getMember().getId()) + .authorNickname(c.getMember().getNickname()) .content(c.getContent()) .createdAt(c.getCreatedAt()) - .mine(c.getAuthorId() != null && c.getAuthorId() == me) + .mine(c.getMember().getId().equals(me)) .build()) .toList(); - return PageResponse.of(content, safePage, safeSize, total); + return PageResponse.of(content, safePage + 1, safeSize, total); } } diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java index bdf112e..8872e88 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -23,6 +24,7 @@ import com.project.InsightPrep.domain.post.exception.PostException; import com.project.InsightPrep.domain.post.mapper.CommentMapper; import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.reqository.CommentRepository; import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -38,6 +40,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class CommentServiceImplTest { @@ -54,6 +59,9 @@ class CommentServiceImplTest { @Mock CommentMapper commentMapper; + @Mock + CommentRepository commentRepository; + @InjectMocks CommentServiceImpl commentService; @@ -95,7 +103,7 @@ void create_success() { idField.set(c, 777L); } catch (Exception ignored) {} return null; - }).when(commentMapper).insertComment(any(Comment.class)); + }).when(commentRepository).save(any(Comment.class)); CreateDto req = new CreateDto("첫 댓글"); @@ -109,7 +117,7 @@ void create_success() { verify(securityUtil).getAuthenticatedMember(); verify(sharedPostRepository).findById(postId); - verify(commentMapper).insertComment(any(Comment.class)); + verify(commentRepository).save(any(Comment.class)); } @Test @@ -136,22 +144,33 @@ class UpdateComment { @Test @DisplayName("성공 - 본인 댓글 & 같은 postId") void update_success() { + // given long postId = 10L; long commentId = 200L; long me = 1L; - when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)) - .thenReturn(new CommentRow(commentId, postId, me, "old")); + Member member = Member.builder().id(me).nickname("tester").build(); + SharedPost post = SharedPost.builder().id(postId).build(); + Comment comment = Comment.builder() + .id(commentId) + .content("old") + .member(member) + .sharedPost(post) + .build(); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(post)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); when(securityUtil.getLoginMemberId()).thenReturn(me); - when(commentMapper.updateContent(commentId, me, "new content")).thenReturn(1); + // when commentService.updateComment(postId, commentId, new UpdateDto("new content")); + // then verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verify(commentMapper).updateContent(commentId, me, "new content"); + + assertThat(comment.getContent()).isEqualTo("new content"); } @Test @@ -176,8 +195,9 @@ void update_post_not_found() { void update_comment_not_found() { long postId = 10L; long commentId = 200L; + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)).thenReturn(null); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.updateComment(postId, commentId, new UpdateDto("x")) @@ -185,8 +205,7 @@ void update_comment_not_found() { .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @@ -195,19 +214,27 @@ void update_comment_not_found() { void update_wrong_postId() { long postId = 10L; long commentId = 200L; - when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - // 댓글이 다른 postId에 속함 - when(commentMapper.findRowById(commentId)) - .thenReturn(new CommentRow(commentId, 999L, 1L, "x")); + + SharedPost requestedPost = SharedPost.builder().id(postId).build(); + SharedPost otherPost = SharedPost.builder().id(999L).build(); + + Member member = Member.builder().id(1L).build(); + Comment comment = Comment.builder() + .id(commentId) + .content("x") + .member(member) + .sharedPost(otherPost) + .build(); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(requestedPost)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); assertThatThrownBy(() -> commentService.updateComment(postId, commentId, new UpdateDto("x")) - ).isInstanceOf(PostException.class) - .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); + ).isInstanceOf(PostException.class).hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @@ -219,9 +246,18 @@ void update_forbidden() { long owner = 1L; long me = 2L; - when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)) - .thenReturn(new CommentRow(commentId, postId, owner, "x")); + SharedPost post = SharedPost.builder().id(postId).build(); + Member author = Member.builder().id(owner).build(); + + Comment comment = Comment.builder() + .id(commentId) + .content("x") + .member(author) + .sharedPost(post) + .build(); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(post)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); when(securityUtil.getLoginMemberId()).thenReturn(me); assertThatThrownBy(() -> @@ -230,9 +266,8 @@ void update_forbidden() { .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verifyNoMoreInteractions(commentMapper); } } @@ -248,18 +283,28 @@ void delete_success() { long commentId = 200L; long me = 1L; - when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)) - .thenReturn(new CommentRow(commentId, postId, me, "x")); + // given + Member member = Member.builder().id(me).build(); + SharedPost post = SharedPost.builder().id(postId).build(); + Comment comment = Comment.builder() + .id(commentId) + .content("x") + .member(member) + .sharedPost(post) + .build(); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(post)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); when(securityUtil.getLoginMemberId()).thenReturn(me); - when(commentMapper.deleteByIdAndMember(commentId, me)).thenReturn(1); + // when commentService.deleteComment(postId, commentId); + // then verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verify(commentMapper).deleteByIdAndMember(commentId, me); + verify(commentRepository).delete(comment); } @Test @@ -285,15 +330,14 @@ void delete_comment_not_found() { long commentId = 200L; when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)).thenReturn(null); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @@ -303,31 +347,49 @@ void delete_wrong_postId() { long postId = 10L; long commentId = 200L; - when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)) - .thenReturn(new CommentRow(commentId, 999L, 1L, "x")); + SharedPost requestedPost = SharedPost.builder().id(postId).build(); + SharedPost otherPost = SharedPost.builder().id(999L).build(); + Member member = Member.builder().id(1L).build(); + + Comment comment = Comment.builder() + .id(commentId) + .content("x") + .member(member) + .sharedPost(otherPost) + .build(); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(requestedPost)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @Test - @DisplayName("실패 - 본인 아님 → COMMENT_FORBIDDEN (현재 구현상 delete 쿼리는 수행됨)") + @DisplayName("실패 - 본인 아님 → COMMENT_FORBIDDEN") void delete_forbidden() { long postId = 10L; long commentId = 200L; long owner = 1L; long me = 2L; - when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); - when(commentMapper.findRowById(commentId)) - .thenReturn(new CommentRow(commentId, postId, owner, "x")); + SharedPost post = SharedPost.builder().id(postId).build(); + Member author = Member.builder().id(owner).build(); + + Comment comment = Comment.builder() + .id(commentId) + .content("x") + .member(author) + .sharedPost(post) + .build(); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(post)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); when(securityUtil.getLoginMemberId()).thenReturn(me); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) @@ -335,9 +397,9 @@ void delete_forbidden() { .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); verify(sharedPostRepository).findById(postId); - verify(commentMapper).findRowById(commentId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verifyNoMoreInteractions(commentMapper); + verify(commentRepository, never()).delete(any()); } } @@ -380,17 +442,34 @@ void basic_paging_and_mine_mapping() { // 댓글 더미(한 개는 내가 쓴 댓글, 한 개는 타인 댓글) var now = LocalDateTime.now(); - List dbRows = List.of( - item(101L, 10L, "me", "my comment", now.minusMinutes(2)), - item(102L, 20L, "u", "your comment", now.minusMinutes(1)) - ); - when(commentMapper.findByPostPaged(postId, size, 0)).thenReturn(dbRows); - when(commentMapper.countByPost(postId)).thenReturn(2L); + + // 댓글 엔티티 2개 (내 댓글, 타인 댓글) + Member meMember = Member.builder().id(me).nickname("me").build(); + Member otherMember = Member.builder().id(20L).nickname("u").build(); + SharedPost post = stubPost(postId); + + Comment myComment = Comment.builder() + .id(101L) + .content("my comment") + .member(meMember) + .sharedPost(post) + .build(); + + Comment yourComment = Comment.builder() + .id(102L) + .content("your comment") + .member(otherMember) + .sharedPost(post) + .build(); + + Page commentPage = new PageImpl<>(List.of(myComment, yourComment)); + when(commentRepository.findBySharedPost_IdOrderByCreatedAtAscIdAsc(eq(postId), any(Pageable.class))) + .thenReturn(commentPage); + when(commentRepository.countBySharedPost_Id(postId)).thenReturn(2L); when(securityUtil.getLoginMemberId()).thenReturn(me); PageResponse pageRes = commentService.getComments(postId, page, size); - // 반환 검증 assertThat(pageRes.getPage()).isEqualTo(1); assertThat(pageRes.getSize()).isEqualTo(10); assertThat(pageRes.getTotalElements()).isEqualTo(2L); @@ -399,29 +478,27 @@ void basic_paging_and_mine_mapping() { // mine 플래그 검증 assertThat(pageRes.getContent().get(0).getCommentId()).isEqualTo(101L); assertThat(pageRes.getContent().get(0).isMine()).isTrue(); - assertThat(pageRes.getContent().get(1).getCommentId()).isEqualTo(102L); assertThat(pageRes.getContent().get(1).isMine()).isFalse(); - // 호출 파라미터 검증 - verify(commentMapper).findByPostPaged(postId, size, 0); - verify(commentMapper).countByPost(postId); + verify(commentRepository).findBySharedPost_IdOrderByCreatedAtAscIdAsc(eq(postId), any(Pageable.class)); + verify(commentRepository).countBySharedPost_Id(postId); verify(securityUtil).getLoginMemberId(); } @Test - @DisplayName("page<1 이면 1로 보정, size>50 이면 50으로 보정하여 limit/offset 계산") + @DisplayName("page<1 이면 1로 보정, size>50 이면 50으로 보정") void page_and_size_sanitization() { long postId = 1L; int reqPage = 0; // 보정 대상 int reqSize = 100; // 보정 대상(최대 50) int safePage = 1; int safeSize = 50; - int expectedOffset = 0; + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(stubPost(postId))); when(securityUtil.getLoginMemberId()).thenReturn(999L); - when(commentMapper.findByPostPaged(postId, safeSize, expectedOffset)).thenReturn(List.of()); - when(commentMapper.countByPost(postId)).thenReturn(0L); + when(commentRepository.findBySharedPost_IdOrderByCreatedAtAscIdAsc(eq(postId), any(Pageable.class))).thenReturn(Page.empty()); + when(commentRepository.countBySharedPost_Id(postId)).thenReturn(0L); PageResponse pageRes = commentService.getComments(postId, reqPage, reqSize); @@ -430,53 +507,30 @@ void page_and_size_sanitization() { assertThat(pageRes.getTotalElements()).isZero(); assertThat(pageRes.getContent()).isEmpty(); - // limit/offset 정확히 호출되었는지 캡쳐로 재확인 - ArgumentCaptor limitCap = ArgumentCaptor.forClass(Integer.class); - ArgumentCaptor offsetCap = ArgumentCaptor.forClass(Integer.class); - verify(commentMapper).findByPostPaged(eq(postId), limitCap.capture(), offsetCap.capture()); - assertThat(limitCap.getValue()).isEqualTo(safeSize); - assertThat(offsetCap.getValue()).isEqualTo(expectedOffset); + verify(commentRepository).findBySharedPost_IdOrderByCreatedAtAscIdAsc(eq(postId), any(Pageable.class)); + verify(commentRepository).countBySharedPost_Id(postId); } @Test - @DisplayName("page가 2 이상이면 올바른 offset 계산((page-1)*size)") + @DisplayName("page가 2 이상이면 올바른 offset(Pageable의 page index 반영)") void offset_calculation_when_page_gt_1() { long postId = 1L; int page = 3; int size = 20; - int expectedOffset = (page - 1) * size; // 40 + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(stubPost(postId))); + when(commentRepository.findBySharedPost_IdOrderByCreatedAtAscIdAsc(eq(postId), any(Pageable.class))).thenReturn(Page.empty()); + when(commentRepository.countBySharedPost_Id(postId)).thenReturn(0L); when(securityUtil.getLoginMemberId()).thenReturn(1L); - when(commentMapper.findByPostPaged(postId, size, expectedOffset)).thenReturn(List.of()); - when(commentMapper.countByPost(postId)).thenReturn(0L); commentService.getComments(postId, page, size); - verify(commentMapper).findByPostPaged(postId, size, expectedOffset); - } - - @Test - @DisplayName("DB가 null authorId를 반환해도 NPE 없이 mine=false 처리") - void null_author_id_safe_mine_false() { - long postId = 1L; - long me = 7L; - - CommentListItem row = CommentListItem.builder() - .commentId(1L) - .authorId(null) // 의도적으로 null - .authorNickname("anon") - .content("hi") - .createdAt(LocalDateTime.now()) - .build(); - - when(commentMapper.findByPostPaged(postId, 10, 0)).thenReturn(List.of(row)); - when(commentMapper.countByPost(postId)).thenReturn(1L); - when(securityUtil.getLoginMemberId()).thenReturn(me); - - PageResponse res = commentService.getComments(postId, 1, 10); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(commentRepository).findBySharedPost_IdOrderByCreatedAtAscIdAsc(eq(postId), pageableCaptor.capture()); + Pageable usedPageable = pageableCaptor.getValue(); - assertThat(res.getContent()).hasSize(1); - assertThat(res.getContent().get(0).isMine()).isFalse(); + assertThat(usedPageable.getPageNumber()).isEqualTo(page - 1); // JPA는 0-based page index + assertThat(usedPageable.getPageSize()).isEqualTo(size); } } } \ No newline at end of file From c9213cac41f1562288cba1f03a6936b20a6967e1 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Thu, 16 Oct 2025 00:37:46 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20mapper=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20import=20=EC=A0=9C=EA=B1=B0=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthServiceImpl.java | 2 -- .../domain/auth/service/EmailServiceImpl.java | 4 ---- .../service/PasswordResetServiceImpl.java | 4 ---- .../post/service/impl/CommentServiceImpl.java | 6 ------ .../service/impl/SharedPostServiceImpl.java | 5 ----- .../question/event/FeedbackEventListener.java | 2 -- .../scheduler/QuestionCleanupScheduler.java | 1 - .../service/impl/AnswerServiceImpl.java | 4 ---- .../service/impl/FeedbackServiceImpl.java | 4 ---- .../service/impl/QuestionServiceImpl.java | 4 ---- .../impl/RecentPromptFilterServiceImpl.java | 3 --- .../service/CustomUserDetailsService.java | 2 -- .../global/auth/util/SecurityUtil.java | 2 -- .../service/AuthServiceImplSignUpTest.java | 4 ---- .../auth/service/AuthServiceImplTest.java | 4 ---- .../auth/service/EmailServiceImplTest.java | 8 -------- .../service/PasswordResetServiceImplTest.java | 12 ++++++------ .../service/impl/CommentServiceImplTest.java | 19 +++++-------------- .../impl/SharedPostServiceImplTest.java | 13 ------------- .../event/FeedbackEventListenerTest.java | 5 ----- .../service/impl/AnswerServiceImplTest.java | 13 +------------ .../service/impl/FeedbackServiceImplTest.java | 5 ----- .../service/impl/QuestionServiceImplTest.java | 11 +---------- .../RecentPromptFilterServiceImplTest.java | 9 ++++----- .../service/CustomUserDetailsServiceTest.java | 4 ---- .../global/auth/util/SecurityUtilTest.java | 4 ---- 26 files changed, 17 insertions(+), 137 deletions(-) diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java index de3375e..d5def29 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java @@ -6,7 +6,6 @@ import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; @@ -32,7 +31,6 @@ public class AuthServiceImpl implements AuthService { private final PasswordEncoder passwordEncoder; - private final AuthMapper authMapper; private final AuthRepository authRepository; private final EmailService emailService; private final SecurityUtil securityUtil; diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java index 7b111ce..061a33d 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/EmailServiceImpl.java @@ -3,8 +3,6 @@ import com.project.InsightPrep.domain.auth.entity.EmailVerification; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; -import com.project.InsightPrep.domain.auth.mapper.EmailMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.auth.repository.EmailRepository; import jakarta.mail.MessagingException; @@ -27,9 +25,7 @@ public class EmailServiceImpl implements EmailService { private final JavaMailSender emailSender; - private final AuthMapper authMapper; private final AuthRepository authRepository; - private final EmailMapper emailMapper; private final EmailRepository emailRepository; private static final long EXPIRE_MINUTES = 10; diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java index 4849250..2e82604 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java @@ -3,8 +3,6 @@ import com.project.InsightPrep.domain.auth.entity.PasswordVerification; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; -import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.auth.repository.PasswordRepository; import com.project.InsightPrep.domain.member.entity.Member; @@ -28,9 +26,7 @@ public class PasswordResetServiceImpl implements PasswordResetService { private static final int DEFAULT_ATTEMPTS = 5; private final EmailService emailService; - private final PasswordMapper passwordMapper; private final PasswordRepository passwordRepository; - private final AuthMapper authMapper; private final AuthRepository authRepository; private final SecurityUtil securityUtil; diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java index df3a6c2..98ad98f 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java @@ -5,19 +5,15 @@ import com.project.InsightPrep.domain.post.dto.CommentRequest.UpdateDto; import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; -import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRow; import com.project.InsightPrep.domain.post.entity.Comment; import com.project.InsightPrep.domain.post.entity.SharedPost; import com.project.InsightPrep.domain.post.exception.PostErrorCode; import com.project.InsightPrep.domain.post.exception.PostException; -import com.project.InsightPrep.domain.post.mapper.CommentMapper; -import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; import com.project.InsightPrep.domain.post.reqository.CommentRepository; import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.post.service.CommentService; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.global.auth.util.SecurityUtil; -import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,9 +30,7 @@ public class CommentServiceImpl implements CommentService { private final SecurityUtil securityUtil; - private final SharedPostMapper sharedPostMapper; private final SharedPostRepository sharedPostRepository; - private final CommentMapper commentMapper; private final CommentRepository commentRepository; @Override diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java index 91d9bde..aab8214 100644 --- a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java @@ -2,14 +2,12 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.post.dto.PostRequest.Create; -import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; import com.project.InsightPrep.domain.post.entity.PostStatus; import com.project.InsightPrep.domain.post.entity.SharedPost; import com.project.InsightPrep.domain.post.exception.PostErrorCode; import com.project.InsightPrep.domain.post.exception.PostException; -import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; import com.project.InsightPrep.domain.post.reqository.CommentRepository; import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.post.service.SharedPostService; @@ -18,7 +16,6 @@ import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; -import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.util.List; @@ -36,9 +33,7 @@ public class SharedPostServiceImpl implements SharedPostService { private final SecurityUtil securityUtil; - private final SharedPostMapper sharedPostMapper; private final SharedPostRepository sharedPostRepository; - private final AnswerMapper answerMapper; private final AnswerRepository answerRepository; private final CommentRepository commentRepository; diff --git a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java index 090275b..4c1e872 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java +++ b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java @@ -5,7 +5,6 @@ import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; -import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.global.gpt.prompt.PromptFactory; import com.project.InsightPrep.global.gpt.service.GptResponseType; @@ -23,7 +22,6 @@ public class FeedbackEventListener { private final GptService gptService; - private final FeedbackMapper feedbackMapper; private final FeedbackRepository feedbackRepository; @Async diff --git a/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java b/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java index 46d2d70..2abfd5e 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java +++ b/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java @@ -1,7 +1,6 @@ package com.project.InsightPrep.domain.question.scheduler; import com.project.InsightPrep.domain.question.entity.AnswerStatus; -import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java index 96c5dde..dcbcbba 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java @@ -9,8 +9,6 @@ import com.project.InsightPrep.domain.question.event.AnswerSavedEvent; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; -import com.project.InsightPrep.domain.question.mapper.AnswerMapper; -import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.AnswerService; @@ -29,9 +27,7 @@ public class AnswerServiceImpl implements AnswerService { private final SecurityUtil securityUtil; - private final QuestionMapper questionMapper; private final QuestionRepository questionRepository; - private final AnswerMapper answerMapper; private final AnswerRepository answerRepository; private final FeedbackService feedbackService; private final ApplicationEventPublisher eventPublisher; diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java index 6b36a0c..a0d2e9b 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java @@ -4,9 +4,6 @@ import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerFeedback; -import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; -import com.project.InsightPrep.domain.question.exception.QuestionException; -import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.domain.question.service.FeedbackService; import com.project.InsightPrep.global.gpt.prompt.PromptFactory; @@ -25,7 +22,6 @@ public class FeedbackServiceImpl implements FeedbackService { private final GptService gptService; - private final FeedbackMapper feedbackMapper; private final FeedbackRepository feedbackRepository; @Transactional diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java index 51ad55e..766e0d1 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java @@ -10,8 +10,6 @@ import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.Question; -import com.project.InsightPrep.domain.question.mapper.AnswerMapper; -import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.QuestionService; @@ -35,9 +33,7 @@ public class QuestionServiceImpl implements QuestionService { private final GptService gptService; - private final QuestionMapper questionMapper; private final QuestionRepository questionRepository; - private final AnswerMapper answerMapper; private final AnswerRepository answerRepository; private final RecentPromptFilterService recentPromptFilterService; private final SecurityUtil securityUtil; diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java index 6256916..09197e9 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java @@ -2,7 +2,6 @@ import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; -import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper; import com.project.InsightPrep.domain.question.repository.RecentPromptFilterRepository; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import java.time.Duration; @@ -11,7 +10,6 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; @@ -24,7 +22,6 @@ public class RecentPromptFilterServiceImpl implements RecentPromptFilterService { private final StringRedisTemplate redis; - private final RecentPromptFilterMapper recentMapper; private final RecentPromptFilterRepository recentPromptFilterRepository; private static final String KEY_FMT = "rp:%d:%s:%s"; // memberId, category, type public static final int MAX_SIZE = 10; diff --git a/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java b/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java index 6dfe63b..f6ce623 100644 --- a/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java +++ b/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java @@ -1,6 +1,5 @@ package com.project.InsightPrep.global.auth.service; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; @@ -14,7 +13,6 @@ @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { - private final AuthMapper authMapper; private final AuthRepository authRepository; @Override diff --git a/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java b/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java index e86da84..d461f6d 100644 --- a/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java +++ b/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java @@ -2,7 +2,6 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; import com.project.InsightPrep.domain.member.entity.Member; @@ -16,7 +15,6 @@ @RequiredArgsConstructor public class SecurityUtil { - private final AuthMapper authMapper; private final AuthRepository authRepository; private final PasswordEncoder passwordEncoder; diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java index 65849a2..bb89b82 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplSignUpTest.java @@ -10,7 +10,6 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; @@ -36,9 +35,6 @@ public class AuthServiceImplSignUpTest { @InjectMocks private AuthServiceImpl authService; - @Mock - private AuthMapper authMapper; - @Mock private AuthRepository authRepository; diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java index a57e78c..7013849 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/AuthServiceImplTest.java @@ -11,7 +11,6 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import org.junit.jupiter.api.DisplayName; @@ -31,9 +30,6 @@ class AuthServiceImplTest { @Mock private PasswordEncoder passwordEncoder; - @Mock - private AuthMapper authMapper; - @Mock private AuthRepository authRepository; diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java index efa8a0e..6c643c1 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/EmailServiceImplTest.java @@ -14,8 +14,6 @@ import com.project.InsightPrep.domain.auth.entity.EmailVerification; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; -import com.project.InsightPrep.domain.auth.mapper.EmailMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.auth.repository.EmailRepository; import jakarta.mail.MessagingException; @@ -40,15 +38,9 @@ class EmailServiceImplTest { @Mock private JavaMailSender emailSender; - @Mock - private AuthMapper authMapper; - @Mock private AuthRepository authRepository; - @Mock - private EmailMapper emailMapper; - @Mock private EmailRepository emailRepository; diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java index cb815df..d860492 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java @@ -3,8 +3,6 @@ import com.project.InsightPrep.domain.auth.entity.PasswordVerification; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; -import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.auth.repository.PasswordRepository; import com.project.InsightPrep.domain.member.entity.Member; @@ -33,13 +31,15 @@ @ExtendWith(MockitoExtension.class) class PasswordResetServiceImplTest { - @Mock EmailService emailService; - @Mock PasswordMapper passwordMapper; + @Mock + EmailService emailService; + @Mock PasswordRepository passwordRepository; - @Mock AuthMapper authMapper; + @Mock AuthRepository authRepository; + @Mock SecurityUtil securityUtil; @@ -82,7 +82,7 @@ void requestOtp_returnsSilently_whenEmailNotExist() throws Exception { service.requestOtp(EMAIL); verifyNoInteractions(emailService); - verifyNoInteractions(passwordMapper); + verifyNoInteractions(passwordRepository); } @Test diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java index 8872e88..cf5d366 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java @@ -17,13 +17,10 @@ import com.project.InsightPrep.domain.post.dto.CommentRequest.UpdateDto; import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; -import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRow; import com.project.InsightPrep.domain.post.entity.Comment; import com.project.InsightPrep.domain.post.entity.SharedPost; import com.project.InsightPrep.domain.post.exception.PostErrorCode; import com.project.InsightPrep.domain.post.exception.PostException; -import com.project.InsightPrep.domain.post.mapper.CommentMapper; -import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; import com.project.InsightPrep.domain.post.reqository.CommentRepository; import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.question.dto.response.PageResponse; @@ -50,15 +47,9 @@ class CommentServiceImplTest { @Mock SecurityUtil securityUtil; - @Mock - SharedPostMapper sharedPostMapper; - @Mock SharedPostRepository sharedPostRepository; - @Mock - CommentMapper commentMapper; - @Mock CommentRepository commentRepository; @@ -132,7 +123,7 @@ void create_post_not_found() { .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verifyNoInteractions(commentMapper); + verifyNoInteractions(commentRepository); } } @@ -186,8 +177,8 @@ void update_post_not_found() { .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verifyNoMoreInteractions(sharedPostMapper); - verifyNoInteractions(commentMapper, securityUtil); + verifyNoMoreInteractions(sharedPostRepository); + verifyNoInteractions(commentRepository, securityUtil); } @Test @@ -320,7 +311,7 @@ void delete_post_not_found() { .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verifyNoInteractions(commentMapper, securityUtil); + verifyNoInteractions(commentRepository, securityUtil); } @Test @@ -418,7 +409,7 @@ void post_not_found() { .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); verify(sharedPostRepository).findById(postId); - verifyNoMoreInteractions(commentMapper, securityUtil); + verifyNoMoreInteractions(commentRepository, securityUtil); } } diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java index eb737e1..2bfc0ce 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java @@ -2,11 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,24 +11,20 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.repository.MemberRepository; import com.project.InsightPrep.domain.post.dto.PostRequest.Create; -import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; import com.project.InsightPrep.domain.post.entity.PostStatus; import com.project.InsightPrep.domain.post.entity.SharedPost; import com.project.InsightPrep.domain.post.exception.PostErrorCode; import com.project.InsightPrep.domain.post.exception.PostException; -import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; import com.project.InsightPrep.domain.post.reqository.CommentRepository; import com.project.InsightPrep.domain.post.reqository.SharedPostRepository; import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; -import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -50,15 +43,9 @@ class SharedPostServiceImplTest { @Mock SecurityUtil securityUtil; - @Mock - SharedPostMapper sharedPostMapper; - @Mock SharedPostRepository sharedPostRepository; - @Mock - AnswerMapper answerMapper; - @Mock AnswerRepository answerRepository; diff --git a/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java b/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java index 3505ea6..ae6cf7a 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java @@ -4,9 +4,7 @@ import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.entity.Question; -import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; -import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.global.gpt.service.GptResponseType; import com.project.InsightPrep.global.gpt.service.GptService; @@ -28,9 +26,6 @@ class FeedbackEventListenerTest { @Mock private GptService gptService; - @Mock - private FeedbackMapper feedbackMapper; - @Mock private FeedbackRepository feedbackRepository; diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java index 20b4b82..45b42b7 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java @@ -4,10 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -24,8 +21,6 @@ import com.project.InsightPrep.domain.question.event.AnswerSavedEvent; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; -import com.project.InsightPrep.domain.question.mapper.AnswerMapper; -import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -52,15 +47,9 @@ class AnswerServiceImplTest { @Mock private SecurityUtil securityUtil; - @Mock - private QuestionMapper questionMapper; - @Mock private QuestionRepository questionRepository; - @Mock - private AnswerMapper answerMapper; - @Mock private AnswerRepository answerRepository; @@ -128,7 +117,7 @@ void saveAnswer_success() { // 반환 DTO 검증 (서비스가 DTO를 반환하도록 구현되어 있다는 가정) assertThat(res).isNotNull(); assertThat(res.getAnswerId()).isEqualTo(100L); - verifyNoMoreInteractions(securityUtil, questionRepository, answerMapper, feedbackService); + verifyNoMoreInteractions(securityUtil, questionRepository, answerRepository, feedbackService); } @Test diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java index b7654e3..4a80742 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java @@ -14,8 +14,6 @@ import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerFeedback; import com.project.InsightPrep.domain.question.entity.Question; -import com.project.InsightPrep.domain.question.exception.QuestionException; -import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; import com.project.InsightPrep.domain.question.repository.FeedbackRepository; import com.project.InsightPrep.global.gpt.service.GptServiceImpl; import java.util.Optional; @@ -36,9 +34,6 @@ class FeedbackServiceImplTest { @Mock private GptServiceImpl gptService; - @Mock - private FeedbackMapper feedbackMapper; - @Mock private FeedbackRepository feedbackRepository; diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java index 789a6a1..f53be75 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java @@ -8,7 +8,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -22,8 +21,6 @@ import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.Question; -import com.project.InsightPrep.domain.question.mapper.AnswerMapper; -import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.domain.question.repository.AnswerRepository; import com.project.InsightPrep.domain.question.repository.QuestionRepository; import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; @@ -47,15 +44,9 @@ class QuestionServiceImplTest { @Mock private GptService gptService; - @Mock - private QuestionMapper questionMapper; - @Mock private QuestionRepository questionRepository; - @Mock - private AnswerMapper answerMapper; - @Mock private AnswerRepository answerRepository; @@ -122,7 +113,7 @@ void createQuestion_ShouldGenerateQuestionAndInsertIntoDatabase() { inOrder.verify(recentPromptFilterService).record(memberId, category, ItemType.TOPIC, "프로세스 vs 스레드"); inOrder.verify(recentPromptFilterService).record(memberId, category, ItemType.KEYWORD, "thread"); - verifyNoMoreInteractions(recentPromptFilterService, gptService, questionMapper, securityUtil); + verifyNoMoreInteractions(recentPromptFilterService, gptService, questionRepository, securityUtil); } @Test diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java index bcaadae..63ddfa4 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java @@ -1,13 +1,11 @@ package com.project.InsightPrep.domain.question.service.impl; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; -import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper; import com.project.InsightPrep.domain.question.repository.RecentPromptFilterRepository; import java.time.Duration; import java.util.*; @@ -18,7 +16,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; @@ -26,10 +23,12 @@ @ExtendWith(MockitoExtension.class) class RecentPromptFilterServiceImplTest { - @Mock StringRedisTemplate redis; - @Mock RecentPromptFilterMapper recentMapper; + @Mock + StringRedisTemplate redis; + @Mock RecentPromptFilterRepository recentPromptFilterRepository; + @Mock ZSetOperations zset; @InjectMocks RecentPromptFilterServiceImpl service; diff --git a/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java b/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java index 286cbae..ee11bc1 100644 --- a/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java +++ b/src/test/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsServiceTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; @@ -20,9 +19,6 @@ @ExtendWith(MockitoExtension.class) class CustomUserDetailsServiceTest { - @Mock - private AuthMapper authMapper; - @Mock private AuthRepository authRepository; diff --git a/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java b/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java index f77f557..5298a61 100644 --- a/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java +++ b/src/test/java/com/project/InsightPrep/global/auth/util/SecurityUtilTest.java @@ -5,7 +5,6 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; -import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.repository.AuthRepository; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; @@ -29,9 +28,6 @@ class SecurityUtilTest { @InjectMocks private SecurityUtil securityUtil; - @Mock - private AuthMapper authMapper; - @Mock private AuthRepository authRepository;