diff --git a/src/main/java/com/project/InsightPrep/InsightPrepApplication.java b/src/main/java/com/project/InsightPrep/InsightPrepApplication.java index 371f8d4..3d52846 100644 --- a/src/main/java/com/project/InsightPrep/InsightPrepApplication.java +++ b/src/main/java/com/project/InsightPrep/InsightPrepApplication.java @@ -2,6 +2,7 @@ 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; 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/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/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/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/AuthServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java index caa91f7..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,7 @@ 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; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; @@ -31,7 +31,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 +47,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..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,8 @@ 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; import jakarta.mail.internet.MimeMessage; import java.time.LocalDateTime; @@ -25,8 +25,8 @@ public class EmailServiceImpl implements EmailService { private final JavaMailSender emailSender; - private final AuthMapper authMapper; - private final EmailMapper emailMapper; + private final AuthRepository authRepository; + private final EmailRepository emailRepository; private static final long EXPIRE_MINUTES = 10; @@ -92,19 +92,21 @@ 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 public void existEmail(String email) { - if (authMapper.existEmail(email)) { + if (authRepository.existsByEmail(email)) { throw new AuthException(AuthErrorCode.EMAIL_DUPLICATE_ERROR); } } @@ -116,7 +118,7 @@ private EmailVerification createVerificationCode(String email) { .code(randomCode) .expiresTime(LocalDateTime.now().plusMinutes(EXPIRE_MINUTES)) // 10분 후 만료 .build(); - emailMapper.insertCode(code); + emailRepository.save(code); return code; } @@ -132,14 +134,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)); @@ -149,15 +152,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/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java index c0dd308..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,8 @@ 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; @@ -26,14 +26,14 @@ public class PasswordResetServiceImpl implements PasswordResetService { private static final int DEFAULT_ATTEMPTS = 5; private final EmailService emailService; - private final PasswordMapper passwordMapper; - private final AuthMapper authMapper; + private final PasswordRepository passwordRepository; + 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) { // 계정이 없으면 그냥 "요청 접수"처럼 리턴 (메일 안보냄) @@ -45,7 +45,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 = """ @@ -72,47 +77,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; } @@ -120,36 +96,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(); - Member member = authMapper.findByEmail(email).orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + String email = verification.getEmail(); + Member member = authRepository.findByEmail(email).orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); // 4) 패스워드 해시 & 저장 - String hashed = securityUtil.encode(newRawPassword); - int updated = authMapper.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/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/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/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..58ec799 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/reqository/CommentRepository.java @@ -0,0 +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/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..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,20 +5,22 @@ 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; +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; @@ -28,18 +30,15 @@ public class CommentServiceImpl implements CommentService { private final SecurityUtil securityUtil; - private final SharedPostMapper sharedPostMapper; - private final CommentMapper commentMapper; + private final SharedPostRepository sharedPostRepository; + private final CommentRepository commentRepository; @Override @Transactional 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()) @@ -47,7 +46,7 @@ public CommentRes createComment(long postId, CreateDto req) { .sharedPost(post) .build(); - commentMapper.insertComment(comment); + commentRepository.save(comment); return CommentRes.builder() .commentId(comment.getId()) @@ -55,77 +54,68 @@ public CommentRes createComment(long postId, CreateDto req) { .authorId(me.getId()) .authorNickname(me.getNickname()) .postId(postId) - .createdAt(LocalDateTime.now()) + .createdAt(comment.getCreatedAt()) .build(); } @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); - - CommentRow comment = commentMapper.findRowById(commentId); - if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + SharedPost post = sharedPostRepository.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.POST_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 = 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 = 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 @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 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/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..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 @@ -1,20 +1,29 @@ 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.mapper.AnswerMapper; +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.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; @@ -24,36 +33,66 @@ public class SharedPostServiceImpl implements SharedPostService { private final SecurityUtil securityUtil; - private final SharedPostMapper sharedPostMapper; - private final AnswerMapper answerMapper; + private final SharedPostRepository sharedPostRepository; + 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 = answerMapper.existsMyAnswer(req.getAnswerId(), memberId); + 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 @@ -61,21 +100,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 @@ -83,10 +120,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/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 bdfe974..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 @@ -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,12 @@ public class Question extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) private AnswerStatus status = AnswerStatus.WAITING; + + 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/event/FeedbackEventListener.java b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java index 98646b9..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,7 @@ 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; import com.project.InsightPrep.global.gpt.service.GptService; @@ -22,7 +22,7 @@ public class FeedbackEventListener { private final GptService gptService; - private final FeedbackMapper feedbackMapper; + private final FeedbackRepository feedbackRepository; @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -43,7 +43,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/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/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/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/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/scheduler/QuestionCleanupScheduler.java b/src/main/java/com/project/InsightPrep/domain/question/scheduler/QuestionCleanupScheduler.java index 384de50..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,19 +1,23 @@ 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; +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..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 @@ -5,19 +5,19 @@ 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; import com.project.InsightPrep.global.auth.util.SecurityUtil; 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; @@ -27,8 +27,8 @@ public class AnswerServiceImpl implements AnswerService { private final SecurityUtil securityUtil; - private final QuestionMapper questionMapper; - private final AnswerMapper answerMapper; + private final QuestionRepository questionRepository; + private final AnswerRepository answerRepository; private final FeedbackService feedbackService; private final ApplicationEventPublisher eventPublisher; @@ -36,7 +36,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,8 +44,8 @@ public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { .content(dto.getContent()) .build(); - questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name()); - answerMapper.insertAnswer(answer); + question.markAsAnswered(); + answerRepository.save(answer); //feedbackService.saveFeedback(answer); eventPublisher.publishEvent(new AnswerSavedEvent(answer)); return AnswerResponse.AnswerDto.builder() @@ -57,27 +57,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/FeedbackServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java index 59ac5ed..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,11 +4,12 @@ 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.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; @@ -21,7 +22,7 @@ public class FeedbackServiceImpl implements FeedbackService { private final GptService gptService; - private final FeedbackMapper feedbackMapper; + private final FeedbackRepository feedbackRepository; @Transactional @Async @@ -37,17 +38,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/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..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 @@ -5,11 +5,13 @@ 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; import com.project.InsightPrep.global.auth.util.SecurityUtil; @@ -20,6 +22,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; @@ -29,8 +33,8 @@ public class QuestionServiceImpl implements QuestionService { private final GptService gptService; - private final QuestionMapper questionMapper; - private final AnswerMapper answerMapper; + private final QuestionRepository questionRepository; + private final AnswerRepository answerRepository; private final RecentPromptFilterService recentPromptFilterService; private final SecurityUtil securityUtil; @@ -57,7 +61,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())) { @@ -82,11 +86,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/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..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,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; @@ -10,7 +10,8 @@ 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; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,7 +22,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 +30,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 +61,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/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java b/src/main/java/com/project/InsightPrep/global/auth/service/CustomUserDetailsService.java index ad0893d..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,6 @@ 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; @@ -13,11 +13,11 @@ @RequiredArgsConstructor 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..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,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; @@ -15,7 +15,7 @@ @RequiredArgsConstructor public class SecurityUtil { - private final AuthMapper authMapper; + private final AuthRepository authRepository; private final PasswordEncoder passwordEncoder; public Long getLoginMemberId() { @@ -36,7 +36,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/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 { +} 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..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,7 @@ 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; import jakarta.servlet.http.HttpServletRequest; @@ -36,7 +36,7 @@ public class AuthServiceImplSignUpTest { private AuthServiceImpl authService; @Mock - private AuthMapper authMapper; + private AuthRepository authRepository; @Mock private PasswordEncoder passwordEncoder; @@ -64,7 +64,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 +81,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 +94,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 +108,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..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,7 @@ 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; import org.junit.jupiter.api.Test; @@ -31,7 +31,7 @@ class AuthServiceImplTest { private PasswordEncoder passwordEncoder; @Mock - private AuthMapper authMapper; + private AuthRepository authRepository; @Mock private EmailService emailService; @@ -55,7 +55,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 +75,7 @@ void passwordUnMatch() { // when & then assertThrows(AuthException.class, () -> authService.signup(dto)); - verify(authMapper, never()).insertMember(any()); + verify(authRepository, never()).save(any()); } @Test @@ -97,7 +97,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..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,8 @@ 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; import jakarta.mail.internet.MimeMessage; import java.lang.reflect.Field; @@ -39,10 +39,10 @@ class EmailServiceImplTest { private JavaMailSender emailSender; @Mock - private AuthMapper authMapper; + private AuthRepository authRepository; @Mock - private EmailMapper emailMapper; + private EmailRepository emailRepository; @Mock private MimeMessage mimeMessage; @@ -98,7 +98,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)); } @@ -112,7 +112,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); @@ -128,7 +128,7 @@ void currentEmailExisting_ExpiredCode_Deletes() throws Exception { emailService.sendCodeToEmail(email); // then - verify(emailMapper).deleteByEmail(email); + verify(emailRepository).deleteByEmail(email); } @Test @@ -139,7 +139,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)); } @@ -154,12 +154,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 @@ -172,7 +172,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)); } @@ -180,7 +180,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")); } @@ -188,7 +188,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")); } @@ -200,7 +200,7 @@ void deleteExpiredVerificationCodes_shouldCallEmailMapperOnce() { emailService.deleteExpiredVerificationCodes(); // then - verify(emailMapper, times(1)).deleteByExpiresTimeBefore(any(LocalDateTime.class)); + verify(emailRepository, times(1)).deleteByExpiresTimeBefore(any(LocalDateTime.class)); } @Test @@ -210,7 +210,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, () -> { 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..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 @@ -1,9 +1,10 @@ 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; @@ -20,15 +21,25 @@ 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.*; @ExtendWith(MockitoExtension.class) class PasswordResetServiceImplTest { - @Mock EmailService emailService; - @Mock PasswordMapper passwordMapper; - @Mock AuthMapper authMapper; + @Mock + EmailService emailService; + + @Mock + PasswordRepository passwordRepository; + + @Mock + AuthRepository authRepository; + @Mock SecurityUtil securityUtil; @@ -44,39 +55,40 @@ void setUp() { @Test @DisplayName("requestOtp: 가입된 이메일이면 OTP 저장 및 메일 발송") void requestOtp_sendsMail_whenEmailExists() throws Exception { - when(authMapper.existEmail(EMAIL)).thenReturn(true); + 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 @DisplayName("requestOtp: 미가입 이메일이면 아무 동작 없이 정상 종료 (정보 유출 방지)") void requestOtp_returnsSilently_whenEmailNotExist() throws Exception { - when(authMapper.existEmail(EMAIL)).thenReturn(false); + when(authRepository.existsByEmail(EMAIL)).thenReturn(false); service.requestOtp(EMAIL); verifyNoInteractions(emailService); - verifyNoInteractions(passwordMapper); + verifyNoInteractions(passwordRepository); } @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()); @@ -89,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 @@ -98,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 ---------- @@ -171,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(authMapper.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(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); - when(passwordMapper.markResetTokenUsed(token)).thenReturn(1); service.resetPassword(token, "newP@ss!"); - verify(authMapper).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(authMapper.findByEmail(EMAIL)).thenReturn(Optional.empty()); + 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(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); - when(securityUtil.encode("pw")).thenReturn("hashed"); - when(authMapper.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(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); - when(securityUtil.encode("pw")).thenReturn("hashed"); - when(authMapper.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); 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..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 @@ -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; @@ -16,17 +17,17 @@ 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; 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; @@ -36,6 +37,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 { @@ -44,10 +48,10 @@ class CommentServiceImplTest { SecurityUtil securityUtil; @Mock - SharedPostMapper sharedPostMapper; + SharedPostRepository sharedPostRepository; @Mock - CommentMapper commentMapper; + CommentRepository commentRepository; @InjectMocks CommentServiceImpl commentService; @@ -80,7 +84,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); @@ -90,7 +94,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("첫 댓글"); @@ -103,23 +107,23 @@ void create_success() { assertThat(res.getPostId()).isEqualTo(postId); verify(securityUtil).getAuthenticatedMember(); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).insertComment(any(Comment.class)); + verify(sharedPostRepository).findById(postId); + verify(commentRepository).save(any(Comment.class)); } @Test @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); - verifyNoInteractions(commentMapper); + verify(sharedPostRepository).findById(postId); + verifyNoInteractions(commentRepository); } } @@ -131,22 +135,33 @@ class UpdateComment { @Test @DisplayName("성공 - 본인 댓글 & 같은 postId") void update_success() { + // given long postId = 10L; long commentId = 200L; long me = 1L; - when(sharedPostMapper.findById(postId)).thenReturn(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")); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); + // then + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verify(commentMapper).updateContent(commentId, me, "new content"); + + assertThat(comment.getContent()).isEqualTo("new content"); } @Test @@ -154,16 +169,16 @@ 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); - verifyNoMoreInteractions(sharedPostMapper); - verifyNoInteractions(commentMapper, securityUtil); + verify(sharedPostRepository).findById(postId); + verifyNoMoreInteractions(sharedPostRepository); + verifyNoInteractions(commentRepository, securityUtil); } @Test @@ -171,17 +186,17 @@ 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(commentMapper.findRowById(commentId)).thenReturn(null); + + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.updateComment(postId, commentId, new UpdateDto("x")) ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @@ -190,19 +205,27 @@ 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()); - // 댓글이 다른 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(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @@ -214,9 +237,18 @@ void update_forbidden() { long owner = 1L; long me = 2L; - when(sharedPostMapper.findById(postId)).thenReturn(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(() -> @@ -224,10 +256,9 @@ void update_forbidden() { ).isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verifyNoMoreInteractions(commentMapper); } } @@ -243,18 +274,28 @@ void delete_success() { long commentId = 200L; long me = 1L; - when(sharedPostMapper.findById(postId)).thenReturn(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); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); + // then + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verify(commentMapper).deleteByIdAndMember(commentId, me); + verify(commentRepository).delete(comment); } @Test @@ -263,14 +304,14 @@ 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); - verifyNoInteractions(commentMapper, securityUtil); + verify(sharedPostRepository).findById(postId); + verifyNoInteractions(commentRepository, securityUtil); } @Test @@ -279,16 +320,15 @@ void delete_comment_not_found() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); - when(commentMapper.findRowById(commentId)).thenReturn(null); + when(sharedPostRepository.findById(postId)).thenReturn(Optional.of(SharedPost.builder().id(postId).build())); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verifyNoInteractions(securityUtil); } @@ -298,41 +338,59 @@ void delete_wrong_postId() { long postId = 10L; long commentId = 200L; - when(sharedPostMapper.findById(postId)).thenReturn(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(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); - verifyNoMoreInteractions(commentMapper); + verify(sharedPostRepository).findById(postId); + 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(sharedPostMapper.findById(postId)).thenReturn(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)) .isInstanceOf(PostException.class) .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); - verify(sharedPostMapper).findById(postId); - verify(commentMapper).findRowById(commentId); + verify(sharedPostRepository).findById(postId); + verify(commentRepository).findById(commentId); verify(securityUtil).getLoginMemberId(); - verifyNoMoreInteractions(commentMapper); + verify(commentRepository, never()).delete(any()); } } @@ -344,14 +402,14 @@ 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); - verifyNoMoreInteractions(commentMapper, securityUtil); + verify(sharedPostRepository).findById(postId); + verifyNoMoreInteractions(commentRepository, securityUtil); } } @@ -362,7 +420,7 @@ class SuccessCases { @BeforeEach void setUp() { // 공통: 게시글 존재 - when(sharedPostMapper.findById(1L)).thenReturn(stubPost(1L)); + when(sharedPostRepository.findById(1L)).thenReturn(Optional.of(stubPost(1L))); } @Test @@ -375,17 +433,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); @@ -394,29 +469,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); @@ -425,53 +498,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 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..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,33 +2,40 @@ 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; +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.mapper.AnswerMapper; +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.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 { @@ -37,10 +44,16 @@ class SharedPostServiceImplTest { SecurityUtil securityUtil; @Mock - SharedPostMapper sharedPostMapper; + SharedPostRepository sharedPostRepository; @Mock - AnswerMapper answerMapper; + AnswerRepository answerRepository; + + @Mock + MemberRepository memberRepository; + + @Mock + CommentRepository commentRepository; @InjectMocks SharedPostServiceImpl service; @@ -51,67 +64,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); - given(answerMapper.existsMyAnswer(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); + 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(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(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(false); + 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(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(true); - given(sharedPostMapper.insertSharedPost(anyString(), anyString(), anyLong(), anyLong(), anyString())) - .willReturn(0); + given(securityUtil.getAuthenticatedMember()).willReturn(member); + given(answerRepository.existsByIdAndMemberId(answerId, memberId)).willReturn(true); + 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 @@ -120,33 +155,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 @@ -156,61 +210,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()); @@ -219,75 +296,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 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..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,8 @@ 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; import org.junit.jupiter.api.BeforeEach; @@ -28,7 +27,7 @@ class FeedbackEventListenerTest { private GptService gptService; @Mock - private FeedbackMapper feedbackMapper; + private FeedbackRepository feedbackRepository; @InjectMocks private FeedbackEventListener feedbackEventListener; @@ -70,7 +69,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 +86,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 +104,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/AnswerServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java index ccbdf33..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,8 @@ 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; import static org.mockito.Mockito.verify; @@ -23,10 +21,11 @@ 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; import java.lang.reflect.Field; +import java.util.Optional; import java.util.concurrent.TimeUnit; import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; @@ -37,6 +36,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 { @@ -48,10 +48,10 @@ class AnswerServiceImplTest { private SecurityUtil securityUtil; @Mock - private QuestionMapper questionMapper; + private QuestionRepository questionRepository; @Mock - private AnswerMapper answerMapper; + private AnswerRepository answerRepository; @Mock private FeedbackServiceImpl feedbackService; @@ -81,9 +81,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); @@ -91,16 +90,17 @@ 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); // then verify(securityUtil).getAuthenticatedMember(); - verify(questionMapper).findById(questionId); - verify(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name())); - verify(answerMapper).insertAnswer(any(Answer.class)); + verify(questionRepository).findById(questionId); + verify(answerRepository).save(any(Answer.class)); + + assertThat(mockQuestion.getStatus()).isEqualTo(AnswerStatus.ANSWERED); // 이벤트 객체 캡처 ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AnswerSavedEvent.class); @@ -117,7 +117,7 @@ void saveAnswer_success() { // 반환 DTO 검증 (서비스가 DTO를 반환하도록 구현되어 있다는 가정) assertThat(res).isNotNull(); assertThat(res.getAnswerId()).isEqualTo(100L); - verifyNoMoreInteractions(securityUtil, questionMapper, answerMapper, feedbackService); + verifyNoMoreInteractions(securityUtil, questionRepository, answerRepository, feedbackService); } @Test @@ -128,7 +128,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)) @@ -136,10 +136,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 @@ -150,9 +151,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)) @@ -160,10 +166,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 @@ -172,13 +177,22 @@ 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; - }).when(answerMapper).insertAnswer(any(Answer.class)); + return arg; + }).when(answerRepository).save(any(Answer.class)); // when answerService.saveAnswer(dto, questionId); @@ -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/FeedbackServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImplTest.java index a18a947..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 @@ -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,9 @@ 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.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; @@ -32,7 +35,7 @@ class FeedbackServiceImplTest { private GptServiceImpl gptService; @Mock - private FeedbackMapper feedbackMapper; + private FeedbackRepository feedbackRepository; @Test @DisplayName("피드백 저장 - GPT 호출과 삽입 테스트") @@ -62,7 +65,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 +97,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 +116,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 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..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; @@ -17,11 +16,13 @@ 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; import com.project.InsightPrep.global.gpt.service.GptService; @@ -34,6 +35,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 { @@ -42,10 +45,10 @@ class QuestionServiceImplTest { private GptService gptService; @Mock - private QuestionMapper questionMapper; + private QuestionRepository questionRepository; @Mock - private AnswerMapper answerMapper; + private AnswerRepository answerRepository; @Mock private RecentPromptFilterService recentPromptFilterService; @@ -81,14 +84,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,16 +104,16 @@ 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"); - verifyNoMoreInteractions(recentPromptFilterService, gptService, questionMapper, securityUtil); + verifyNoMoreInteractions(recentPromptFilterService, gptService, questionRepository, securityUtil); } @Test @@ -120,19 +123,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(); - when(answerMapper.findQuestionsWithFeedbackPaged(memberId, size, offset)) - .thenReturn(List.of(dto)); - when(answerMapper.countQuestionsWithFeedback(memberId)) - .thenReturn(23L); // 총 23건 → 10개씩이면 총 3페이지 + AnswerFeedback feedback = AnswerFeedback.builder() + .id(1000L).score(90).improvement("개선점").modelAnswer("...").build(); + + Answer answer = Answer.builder() + .id(100L).question(question).content("비교").feedback(feedback).build(); + + Pageable pageable = PageRequest.of(page - 1, size); + when(answerRepository.findAllWithQuestionAndFeedbackByMemberId(memberId, pageable)) + .thenReturn(List.of(answer)); // when PageResponse res = questionService.getQuestions(page, size); @@ -142,16 +148,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); + + 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, answerMapper); + 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 @@ -162,28 +169,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 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..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,12 @@ 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.*; import org.junit.jupiter.api.BeforeEach; @@ -17,15 +16,19 @@ 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; @ExtendWith(MockitoExtension.class) class RecentPromptFilterServiceImplTest { - @Mock StringRedisTemplate redis; - @Mock RecentPromptFilterMapper recentMapper; + @Mock + StringRedisTemplate redis; + + @Mock + RecentPromptFilterRepository recentPromptFilterRepository; + @Mock ZSetOperations zset; @InjectMocks RecentPromptFilterServiceImpl service; @@ -49,17 +52,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 +83,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/then - assertDoesNotThrow(() -> service.record(memberId, category, type, value)); + when(zset.size(key)).thenReturn(5L); // trim 없음 - // DB insert 시도는 했지만, 예외는 흡수 - verify(recentMapper).insert(any(RecentPromptFilter.class)); + // when + service.record(memberId, category, type, value); - // Redis는 정상 갱신 + // then: DB 저장은 안됨 + verify(recentPromptFilterRepository).existsByMemberIdAndCategoryAndItemTypeAndItemValue(memberId, category, type, value); + verify(recentPromptFilterRepository, never()).save(any()); + + // 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 +110,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 +121,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 +132,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)); } } 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..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,7 @@ 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; import java.util.Optional; @@ -20,7 +20,7 @@ class CustomUserDetailsServiceTest { @Mock - private AuthMapper authMapper; + private AuthRepository authRepository; @InjectMocks private CustomUserDetailsService customUserDetailsService; @@ -38,7 +38,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 +56,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..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,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; @@ -29,7 +29,7 @@ class SecurityUtilTest { private SecurityUtil securityUtil; @Mock - private AuthMapper authMapper; + private AuthRepository authRepository; @AfterEach void clearContext() { @@ -104,7 +104,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 +129,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, () -> {