diff --git a/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java b/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java index 944aead..203cedf 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java @@ -2,11 +2,14 @@ import com.project.InsightPrep.domain.auth.controller.docs.AuthControllerDocs; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest; +import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.ResetTokenRes; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +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.service.AuthService; import com.project.InsightPrep.domain.auth.service.EmailService; +import com.project.InsightPrep.domain.auth.service.PasswordResetService; import com.project.InsightPrep.global.common.response.ApiResponse; import com.project.InsightPrep.global.common.response.code.ApiSuccessCode; import jakarta.servlet.http.Cookie; @@ -17,6 +20,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -29,6 +33,7 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; private final EmailService emailService; + private final PasswordResetService passwordResetService; @PostMapping("/signup") public ResponseEntity> signup(@RequestBody @Valid AuthRequest.signupDto request) { @@ -79,4 +84,32 @@ public ResponseEntity> logout (HttpServletRequest request, HttpSe return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.LOGOUT_SUCCESS)); } + + @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + @GetMapping("/me") + public ResponseEntity> getSessionInfo(HttpSession session) { + MeDto dto = authService.getSessionInfo(session); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.ME_SUCCESS, dto)); + } + + @Override + @PostMapping("/otp/sendEmail") + public ResponseEntity> requestOtp(@RequestBody @Valid AuthRequest.MemberEmailDto req) { + passwordResetService.requestOtp(req.getEmail()); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SEND_EMAIL_SUCCESS)); // 존재 여부 노출 금지 + } + + @Override + @PostMapping("/otp/verify") + public ResponseEntity> verifyOtp(@RequestBody @Valid AuthRequest.VerifyOtpReq req) { + String token = passwordResetService.verifyOtp(req.email(), req.code()); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.VERIFIED_EMAIL_SUCCESS, new ResetTokenRes(token))); + } + + @Override + @PostMapping("/password/reset") + public ResponseEntity> reset(@RequestBody @Valid AuthRequest.ResetReq req) { + passwordResetService.resetPassword(req.resetToken(), req.newPassword()); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SUCCESS)); + } } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java index a998cbe..0afe259 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java @@ -1,7 +1,9 @@ package com.project.InsightPrep.domain.auth.controller.docs; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest; +import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.ResetTokenRes; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; import com.project.InsightPrep.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -29,4 +31,16 @@ public interface AuthControllerDocs { @Operation(summary = "로그아웃", description = "로그아웃을 진행하면 쿠키가 삭제됩니다.") public ResponseEntity> logout (HttpServletRequest request, HttpServletResponse response); + + @Operation(summary = "세션 조회", description = "로그인 한 사용자가 현재 나의 세션이 유효한지, 그리고 로그인한 사용자가 누구인지 조회합니다.") + public ResponseEntity> getSessionInfo(HttpSession session); + + @Operation(summary = "비밀번호 재설정 인증번호 전송", description = "비밀번호 재전송을 하기 위해 인증번호를 해당 메일로 전송합니다.") + public ResponseEntity> requestOtp(@RequestBody @Valid AuthRequest.MemberEmailDto req); + + @Operation(summary = "비밀번호 재설정 인증번호 인증", description = "비밀번호 재설정을 위한 인증번호를 인증합니다.") + public ResponseEntity> verifyOtp(@RequestBody @Valid AuthRequest.VerifyOtpReq req); + + @Operation(summary = "비밀번호 재설정", description = "비밀번호를 재설정합니다.") + public ResponseEntity> reset(@RequestBody @Valid AuthRequest.ResetReq req); } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java b/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java index 1511bb1..bebf625 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java @@ -41,6 +41,7 @@ public static class signupDto { @Getter @NoArgsConstructor + @Setter public static class MemberEmailDto { @NotBlank @Email @@ -70,4 +71,16 @@ public static class LoginDto { private boolean autoLogin; } + + public record VerifyOtpReq( + @NotBlank @Email String email, + @NotBlank String code + ) {} + + public static record ResetReq( + @NotBlank String resetToken, + @NotBlank(message = "비밀번호는 필수입니다.") @Pattern(regexp = "^(?=.*[A-Z])(?=.*[@$!%*?&]).{8,16}$", message = "비밀번호는 영문 소문자, 대문자, 특수 문자로 구성되어야 합니다.") String newPassword + ) {} + + public static record ResetTokenRes(String resetToken) {} } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java b/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java index 0114f99..4691fb0 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java @@ -27,4 +27,12 @@ public static class LoginResultDto { private Long memberId; private String nickname; } + + @Getter + @Builder + @JsonInclude(Include.NON_NULL) + public static class MeDto { + private Long memberId; + private String nickname; + } } 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 new file mode 100644 index 0000000..70ecec2 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/entity/PasswordVerification.java @@ -0,0 +1,61 @@ +package com.project.InsightPrep.domain.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "password_verification") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PasswordVerification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 254) + private String email; + + @Column(name = "code_hash", nullable = false, length = 255) + private String codeHash; + + // 남은 시도 횟수 (예: 기본 5회) + @Column(nullable = false) + private int attemptsLeft; + + // 1회용 처리 플래그 + @Column(nullable = false) + private boolean used; + + // 만료 시각 + @Column(nullable = false) + private LocalDateTime expiresAt; + + // 감사/운영용 + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime usedAt; + + // 비밀번호 재설정 토큰(OTP 성공 후 발급) + @Column(name = "reset_token", length = 255) + private String resetToken; + + @Column(name = "reset_expires_at") + private LocalDateTime resetExpiresAt; + + @Column(name = "reset_used", nullable = false) + private boolean resetUsed; +} diff --git a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java index 386fa9e..f1cf8c8 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java @@ -16,13 +16,22 @@ public enum AuthErrorCode implements BaseErrorCode { // 이메일 EMAIL_DUPLICATE_ERROR("EMAIL_DUPLICATE_ERROR", HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), EMAIL_VERIFICATION_ERROR("EMAIL_VERIFICATION_ERROR", HttpStatus.BAD_REQUEST, "인증된 이메일이 아닙니다."), - EXPIRED_CODE_ERROR("LINK_EXPIRED_ERROR", HttpStatus.BAD_REQUEST, "코드 입력 시간이 만료되었습니다."), + EXPIRED_CODE_ERROR("EXPIRED_CODE_ERROR", HttpStatus.BAD_REQUEST, "코드 입력 시간이 만료되었습니다."), ALREADY_SEND_CODE_ERROR("ALREADY_SEND_CODE_ERROR", HttpStatus.BAD_REQUEST, "이미 유효한 인증 코드가 발급되었습니다."), + OTP_ALREADY_USED("OTP_ALREADY_USED", HttpStatus.BAD_REQUEST, "이미 사용한 인증 코드입니다."), CODE_NOT_MATCH_ERROR("CODE_NOT_MATCH_ERROR", HttpStatus.BAD_REQUEST, "인증 코드가 일치하지 않습니다"), UNAUTHORIZED("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, "비로그인 상태입니다."), MEMBER_NOT_FOUND("MEMBER_NOT_FOUND", HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다."), NOT_AUTHENTICATED("NOT_AUTHENTICATED", HttpStatus.UNAUTHORIZED, "로그인 정보가 없습니다."), - INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다."); + INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다."), + NEED_LOGIN_ERROR("NEED_LOGIN_ERROR", HttpStatus.BAD_REQUEST, "로그인이 필요합니다."), + OTP_INVALID("OTP_INVALID", HttpStatus.FORBIDDEN, "실패가 누적되어 인증 번호가 만료되었습니다."), + OTP_INVALID_ATTEMPT("OTP_INVALID_ATTEMPT", HttpStatus.BAD_REQUEST, "유효하지 않은 시도입니다."), + + RESET_TOKEN_INVALID("RESET_TOKEN_INVALID", HttpStatus.BAD_REQUEST, "비밀번호 재설정 토큰이 유효하지 않습니다."), + RESET_TOKEN_ALREADY_USED("RESET_TOKEN_ALREADY_USED", HttpStatus.BAD_REQUEST, "이미 사용된 재설정 토큰입니다."), + RESET_TOKEN_EXPIRED("RESET_TOKEN_EXPIRED", HttpStatus.BAD_REQUEST, "재설정 토큰이 만료되었습니다."), + SERVER_ERROR("SERVER_ERROR", HttpStatus.BAD_REQUEST, "서버 에러"); private final String code; diff --git a/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java b/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java index f2eaa8b..886fbb1 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java @@ -15,4 +15,7 @@ public interface AuthMapper { Optional findByEmail(String email); Optional findById(@Param("id") Long id); + + int updatePasswordByEmail(@Param("email") String email, + @Param("passwordHash") String passwordHash); } 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 new file mode 100644 index 0000000..17a80b4 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java @@ -0,0 +1,33 @@ +package com.project.InsightPrep.domain.auth.mapper; + +import com.project.InsightPrep.domain.auth.entity.PasswordVerification; +import java.time.LocalDateTime; +import org.apache.ibatis.annotations.Mapper; +import org.springframework.data.repository.query.Param; + +@Mapper +public interface PasswordMapper { + + void upsertPasswordOtp(@Param("email") String email, + @Param("codeHash") String codeHash, + @Param("attemptsLeft") int attemptsLeft, + @Param("used") boolean used, + @Param("expiresAt") LocalDateTime expiresAt, + @Param("createdAt") LocalDateTime createdAt); + + PasswordVerification findByEmail(@Param("email") String email); + + int updateAttempts(@Param("email") String email, + @Param("attemptsLeft") int attemptsLeft); + + int updateOtpAsUsed(@Param("email") String email); + + int updateResetToken(@Param("email") String email, + @Param("resetToken") String resetToken, + @Param("resetUsed") boolean resetUsed, + @Param("resetExpiresAt") LocalDateTime resetExpiresAt); + + PasswordVerification findByResetToken(@Param("resetToken") String resetToken); + + int markResetTokenUsed(@Param("resetToken") String resetToken); +} diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java index d002611..36905f8 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java @@ -3,9 +3,13 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; +import jakarta.servlet.http.HttpSession; public interface AuthService { void signup(signupDto request); LoginResultDto login(LoginDto request); + + MeDto getSessionInfo(HttpSession session); } 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 be82763..caa91f7 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 @@ -3,12 +3,14 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +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.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; +import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @@ -31,6 +33,7 @@ public class AuthServiceImpl implements AuthService { private final PasswordEncoder passwordEncoder; private final AuthMapper authMapper; private final EmailService emailService; + private final SecurityUtil securityUtil; @Override @Transactional @@ -83,6 +86,19 @@ public LoginResultDto login(LoginDto dto) { return new LoginResultDto(member.getId(), member.getNickname()); } + @Override + public MeDto getSessionInfo(HttpSession session) { + if (session == null) { + throw new AuthException(AuthErrorCode.NEED_LOGIN_ERROR); + } + + Member member = securityUtil.getAuthenticatedMember(); + return MeDto.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .build(); + } + private void validateSignUp(signupDto dto) { emailService.existEmail(dto.getEmail()); emailService.validateEmailVerified(dto.getEmail()); // 이메일 인증 여부 diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java new file mode 100644 index 0000000..022888e --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java @@ -0,0 +1,9 @@ +package com.project.InsightPrep.domain.auth.service; + +public interface PasswordResetService { + void requestOtp(String email); + + String verifyOtp(String email, String code); + + void resetPassword(String resetToken, String newRawPassword); +} 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 new file mode 100644 index 0000000..c0dd308 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java @@ -0,0 +1,165 @@ +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.member.entity.Member; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import jakarta.mail.MessagingException; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class PasswordResetServiceImpl implements PasswordResetService { + + private static final long EXPIRE_MINUTES = 10L; // 10분 + private static final int DEFAULT_ATTEMPTS = 5; + + private final EmailService emailService; + private final PasswordMapper passwordMapper; + private final AuthMapper authMapper; + private final SecurityUtil securityUtil; + + @Override + @Transactional + public void requestOtp(String email) { + boolean exists = authMapper.existEmail(email); + // exists=false여도 "요청 접수" 응답은 동일하게. (계정 존재 여부 노출 방지) + if (!exists) { + // 계정이 없으면 그냥 "요청 접수"처럼 리턴 (메일 안보냄) + return; + } + + String code = generateCode(6); + String codeHash = BCrypt.hashpw(code, BCrypt.gensalt()); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiresAt = now.plusMinutes(EXPIRE_MINUTES); + + passwordMapper.upsertPasswordOtp(email, codeHash, DEFAULT_ATTEMPTS, false, expiresAt, now); + + String title = "InsightPrep 비밀번호 재설정 인증 코드"; + String content = """ + + +

비밀번호 재설정 인증 코드

+

%s

+

해당 코드를 10분 내에 입력해 주세요.

+
+

+ ※본 메일은 자동발송 메일입니다. 회신하지 마세요. +

+ + + """.formatted(code); + + try { + emailService.sendEmail(email, title, content); + } catch (MessagingException | RuntimeException e) { + throw new RuntimeException("메일 전송 실패", e); + } + } + + @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); + } + } + + // 5. 성공 처리: OTP 사용 처리 및 비밀번호 재설정 토큰 발급 + passwordMapper.updateOtpAsUsed(email); + + 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); + } + + return token; + } + + @Override + @Transactional + public void resetPassword(String resetToken, String newRawPassword) { + // 1) 토큰으로 레코드 조회 + PasswordVerification row = passwordMapper.findByResetToken(resetToken); + if (row == null) { + throw new AuthException(AuthErrorCode.RESET_TOKEN_INVALID); + } + + // 2) 토큰 사용 여부/만료 검사 + if (row.isResetUsed()) { + throw new AuthException(AuthErrorCode.RESET_TOKEN_ALREADY_USED); + } + if (row.getResetExpiresAt() == null || row.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)); + + // 4) 패스워드 해시 & 저장 + String hashed = securityUtil.encode(newRawPassword); + int updated = authMapper.updatePasswordByEmail(email, hashed); + if (updated != 1) { + throw new AuthException(AuthErrorCode.SERVER_ERROR); + } + + // 5) 토큰 1회용 처리 + 무효화(선호에 따라 토큰 null 로도) + int done = passwordMapper.markResetTokenUsed(resetToken); + if (done != 1) { + // 여기서 실패하면 롤백 유도 + throw new AuthException(AuthErrorCode.SERVER_ERROR); + } + } + + // 대문자+숫자 6자리 + private String generateCode(int length) { + String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + StringBuilder sb = new StringBuilder(length); + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + for (int i = 0; i < length; i++) { + sb.append(characters.charAt(rnd.nextInt(characters.length()))); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java b/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java index 9d3cca3..dfa92ae 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java +++ b/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java @@ -4,6 +4,7 @@ import com.project.InsightPrep.domain.question.dto.request.AnswerRequest; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.AnswerDto; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.FeedbackDto; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.domain.question.service.AnswerService; @@ -12,7 +13,6 @@ import com.project.InsightPrep.global.common.response.ApiResponse; import com.project.InsightPrep.global.common.response.code.ApiSuccessCode; import jakarta.validation.Valid; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -45,8 +46,8 @@ public ResponseEntity> createQuestion( @PostMapping("/{questionId}/answer") @PreAuthorize("hasAnyRole('USER')") public ResponseEntity> saveAnswer(@RequestBody @Valid AnswerRequest.AnswerDto dto, @PathVariable Long questionId) { - answerService.saveAnswer(dto, questionId); - return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SAVE_ANSWER_SUCCESS)); + AnswerDto answerDto = answerService.saveAnswer(dto, questionId); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SAVE_ANSWER_SUCCESS, answerDto)); } @Override @@ -63,8 +64,11 @@ public ResponseEntity> getFeedback(@PathVariable long a @Override @GetMapping @PreAuthorize("hasAnyRole('USER')") - public ResponseEntity>> getQuestions() { - List dto = questionService.getQuestions(); + public ResponseEntity>> getQuestions( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ) { + PageResponse dto = questionService.getQuestions(page, size); return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.GET_QUESTIONS_SUCCESS, dto)); } diff --git a/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java b/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java index 20c1341..3210714 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java +++ b/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java @@ -3,15 +3,17 @@ import com.project.InsightPrep.domain.question.dto.request.AnswerRequest; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.AnswerDto; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse; +import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Question", description = "Question 관련 API") public interface QuestionControllerDocs { @@ -26,7 +28,10 @@ public interface QuestionControllerDocs { public ResponseEntity> getFeedback(@PathVariable long answerId); @Operation(summary = "면접 질문들 조회", description = "본인이 답변한 질문들을 리스트로 조회합니다.") - public ResponseEntity>> getQuestions(); + public ResponseEntity>> getQuestions( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ); @Operation(summary = "특정 면접 질문 삭제", description = "본인이 답변한 질문들을 리스트로 조회했을 때, 원하는 질문에 대하여 삭제합니다. 해당 질문 삭제 시, 질문에 대한 답변과 피드백 모두 삭제됩니다. " + "답변 id로 삭제가 진행되며 피드백이 연쇄 삭제되고, 질문은 상태가 WAITING으로 수정되어 자동으로 삭제됩니다.") diff --git a/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java b/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java index 88eb80f..a37c04c 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java +++ b/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java @@ -12,8 +12,7 @@ public class AnswerResponse { @JsonInclude(Include.NON_NULL) public static class AnswerDto { - private long id; - private String content; + private long answerId; } @Getter diff --git a/src/main/java/com/project/InsightPrep/domain/question/dto/response/PageResponse.java b/src/main/java/com/project/InsightPrep/domain/question/dto/response/PageResponse.java new file mode 100644 index 0000000..66f9c41 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/dto/response/PageResponse.java @@ -0,0 +1,22 @@ +package com.project.InsightPrep.domain.question.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(staticName = "of") +public class PageResponse { + + private List content; + private int page; + private int size; + private long totalElements; + + public long getTotalPages() { + return (totalElements + size - 1) / size; + } + + public boolean isFirst() { return page <= 1; } + public boolean isLast() { return page >= getTotalPages(); } +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java b/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java index a4f0f9e..b9e7c4d 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java +++ b/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java @@ -12,6 +12,10 @@ public interface AnswerMapper { List findQuestionsWithFeedback(long memberId); + List findQuestionsWithFeedbackPaged(@Param("memberId") long memberId, @Param("limit") int limit, @Param("offset") int offset); + + long countQuestionsWithFeedback(@Param("memberId") long memberId); + Long findQuestionIdOfMyAnswer(@Param("answerId") long answerId, @Param("memberId") long memberId); int deleteMyAnswerById(@Param("answerId") long answerId, @Param("memberId") long memberId); diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java b/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java index a3cb023..78f3d89 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java @@ -1,10 +1,11 @@ package com.project.InsightPrep.domain.question.service; import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; +import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; public interface AnswerService { - void saveAnswer(AnswerDto dto, Long questionId); + AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId); void deleteAnswer(long answerId); } \ No newline at end of file diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/QuestionService.java b/src/main/java/com/project/InsightPrep/domain/question/service/QuestionService.java index 8f4f862..6d02ef5 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/QuestionService.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/QuestionService.java @@ -1,12 +1,12 @@ package com.project.InsightPrep.domain.question.service; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; -import java.util.List; public interface QuestionService { QuestionResponse.QuestionDto createQuestion(String category); - List getQuestions(); + PageResponse getQuestions(int page, int size); } 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 351482f..a844d40 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 @@ -2,6 +2,7 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; +import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; @@ -29,7 +30,7 @@ public class AnswerServiceImpl implements AnswerService { @Override @Transactional - public void saveAnswer(AnswerDto dto, Long questionId) { + public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { Member member = securityUtil.getAuthenticatedMember(); Question question = questionMapper.findById(questionId); @@ -42,6 +43,8 @@ public void saveAnswer(AnswerDto dto, Long questionId) { questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name()); answerMapper.insertAnswer(answer); feedbackService.saveFeedback(answer); + return AnswerResponse.AnswerDto.builder() + .answerId(answer.getId()).build(); } @Override 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 9ebacc3..9967890 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 @@ -1,6 +1,6 @@ package com.project.InsightPrep.domain.question.service.impl; -import com.project.InsightPrep.domain.member.entity.Member; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; 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.QuestionDto; @@ -53,8 +53,15 @@ public QuestionDto createQuestion(String category) { @Override @Transactional(readOnly = true) - public List getQuestions() { + public PageResponse getQuestions(int page, int size) { long memberId = securityUtil.getLoginMemberId(); - return answerMapper.findQuestionsWithFeedback(memberId); + + 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); } } 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 5663d13..066c8f2 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 @@ -9,12 +9,14 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.security.crypto.password.PasswordEncoder; @Component @RequiredArgsConstructor public class SecurityUtil { private final AuthMapper authMapper; + private final PasswordEncoder passwordEncoder; public Long getLoginMemberId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -37,4 +39,28 @@ public Member getAuthenticatedMember() { return authMapper.findById(memberId) .orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); } + + /** + * Compare a raw value with an encoded hash using the configured PasswordEncoder. + * Returns false if either argument is null. + */ + public boolean matches(String raw, String encoded) { + if (raw == null || encoded == null) { + return false; + } + return passwordEncoder.matches(raw, encoded); + } + + /** + * Encode a raw value using the configured PasswordEncoder. + * @param raw the plain text value to encode (must not be null) + * @return encoded hash + * @throws IllegalArgumentException if raw is null + */ + public String encode(String raw) { + if (raw == null) { + throw new IllegalArgumentException("raw must not be null"); + } + return passwordEncoder.encode(raw); + } } diff --git a/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java b/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java index d11393d..f1e43a0 100644 --- a/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java +++ b/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java @@ -21,7 +21,8 @@ public enum ApiSuccessCode { GET_FEEDBACK_SUCCESS("GET_FEEDBACK_SUCCESS", HttpStatus.OK, "피드백 조회 성공"), FEEDBACK_PENDING("FEEDBACK_PENDING", HttpStatus.ACCEPTED, "피드백 생성 중입니다."), GET_QUESTIONS_SUCCESS("GET_QUESTIONS_SUCCESS", HttpStatus.OK, "질문 리스트 조회 성공"), - DELETE_QUESTION_SUCCESS("DELETE_QUESTION_SUCCESS", HttpStatus.OK, "질문과 답변, 피드백 삭제 성공"); + DELETE_QUESTION_SUCCESS("DELETE_QUESTION_SUCCESS", HttpStatus.OK, "질문과 답변, 피드백 삭제 성공"), + ME_SUCCESS("ME_SUCCESS", HttpStatus.OK, "로그인 상태입니다."); private final String code; private final HttpStatus status; diff --git a/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java b/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java new file mode 100644 index 0000000..4e75ed7 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.project.InsightPrep.global.config; + +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:8080")); + cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); + cfg.setAllowedHeaders(List.of("*")); + cfg.setAllowCredentials(true); + cfg.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } +} diff --git a/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java b/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java index fb8a521..c49667a 100644 --- a/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java +++ b/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity @@ -26,6 +27,7 @@ public class SecurityConfig { private final CustomUserDetailsService userDetailsService; private final CustomAccessDeniedHandler customAccessDeniedHandler; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CorsConfigurationSource corsConfigurationSource; @Bean public PasswordEncoder passwordEncoder() { @@ -41,6 +43,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // 개발 중 Swagger 등 요청 허용 + .cors(cors -> cors.configurationSource(corsConfigurationSource)) .anonymous(AbstractHttpConfigurer::disable) // 익명 세션 미적용 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 세션 사용 가능하도록 설정 @@ -49,7 +52,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .formLogin(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", - "/auth/login", "/auth/signup", "/auth/sendEmail", "/auth/verifyEmail").permitAll() + "/auth/login", "/auth/signup", "/auth/sendEmail", "/auth/verifyEmail", "/auth/otp/**", "/auth/password/reset").permitAll() .anyRequest().authenticated() ) .exceptionHandling(exception -> exception diff --git a/src/main/java/com/project/InsightPrep/global/config/WebConfig.java b/src/main/java/com/project/InsightPrep/global/config/WebConfig.java index a21caeb..973e8af 100644 --- a/src/main/java/com/project/InsightPrep/global/config/WebConfig.java +++ b/src/main/java/com/project/InsightPrep/global/config/WebConfig.java @@ -10,8 +10,9 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("http://localhost:8080") - .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedOrigins("http://localhost:8080", "http://localhost:5173") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") .allowCredentials(true); // 세션 쿠키 사용할 경우 true } } diff --git a/src/main/resources/mapper/answer-mapper.xml b/src/main/resources/mapper/answer-mapper.xml index 5d250dc..47eabcb 100644 --- a/src/main/resources/mapper/answer-mapper.xml +++ b/src/main/resources/mapper/answer-mapper.xml @@ -3,12 +3,13 @@ - - - SELECT currval(pg_get_serial_sequence('answer', 'id')) - + + + + INSERT INTO answer (created_at, updated_at, member_id, question_id, content) VALUES (NOW(), NOW(), #{member.id}, #{question.id}, #{content}) + RETURNING id + SELECT COUNT(*) + FROM answer a + INNER JOIN answer_feedback f ON f.answer_id = a.id + WHERE a.member_id = #{memberId} + + + + UPDATE member + SET password = #{passwordHash}, + updated_at = NOW() + WHERE email = #{email} + + \ No newline at end of file diff --git a/src/main/resources/mapper/password-mapper.xml b/src/main/resources/mapper/password-mapper.xml new file mode 100644 index 0000000..9c96084 --- /dev/null +++ b/src/main/resources/mapper/password-mapper.xml @@ -0,0 +1,77 @@ + + + + + + + INSERT INTO password_verification + (email, code_hash, attempts_left, used, expires_at, created_at, reset_used) + VALUES + (#{email}, #{codeHash}, #{attemptsLeft}, #{used}, #{expiresAt}, #{createdAt}, FALSE) + ON CONFLICT (email) DO UPDATE + SET code_hash = EXCLUDED.code_hash, + attempts_left = EXCLUDED.attempts_left, + used = EXCLUDED.used, + expires_at = EXCLUDED.expires_at, + created_at = EXCLUDED.created_at + + + + + + + + UPDATE password_verification + SET attempts_left = #{attemptsLeft} + WHERE email = #{email} + + + + + UPDATE password_verification + SET used = TRUE, + used_at = NOW() + WHERE email = #{email} + + + + + UPDATE password_verification + SET reset_token = #{resetToken}, + reset_expires_at = #{resetExpiresAt}, + reset_used = #{resetUsed} + WHERE email = #{email} + + + + + + UPDATE password_verification + SET reset_used = TRUE, + used_at = NOW(), + reset_token = NULL, + reset_expires_at = NULL + WHERE reset_token = #{resetToken} + + + \ No newline at end of file diff --git a/src/main/resources/sql-map-config.xml b/src/main/resources/sql-map-config.xml index b461f9d..8ed36ad 100644 --- a/src/main/resources/sql-map-config.xml +++ b/src/main/resources/sql-map-config.xml @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java index 32dbe0f..59201ad 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java @@ -1,7 +1,10 @@ package com.project.InsightPrep.domain.auth.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -14,6 +17,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.service.AuthService; +import com.project.InsightPrep.domain.auth.service.PasswordResetService; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; import com.project.InsightPrep.domain.auth.service.EmailService; import com.project.InsightPrep.domain.member.entity.Member; @@ -54,6 +58,9 @@ class AuthControllerTest { @MockitoBean private CustomAuthenticationEntryPoint authenticationEntryPoint; + @MockitoBean + private PasswordResetService passwordResetService; + @Autowired private ObjectMapper objectMapper; @@ -251,6 +258,130 @@ void logout_success() throws Exception { .andExpect(jsonPath("$.message").value("로그아웃 성공")); } + @Test + @DisplayName("OTP 이메일 요청 - 항상 200 (존재 여부 노출 금지)") + void requestOtp_success_always200() throws Exception { + // given + AuthRequest.MemberEmailDto req = new AuthRequest.MemberEmailDto(); + req.setEmail("test@example.com"); + + doNothing().when(passwordResetService).requestOtp("test@example.com"); + + // when & then + mockMvc.perform(post("/auth/otp/sendEmail") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SEND_EMAIL_SUCCESS")); + } + + @Test + @DisplayName("OTP 인증 성공 - 토큰 반환") + void verifyOtp_success_returnsToken() throws Exception { + // given + String email = "test@example.com"; + String code = "ABC123"; + String token = "reset-token-123"; + + AuthRequest.VerifyOtpReq req = new AuthRequest.VerifyOtpReq(email, code); + + given(passwordResetService.verifyOtp(email, code)).willReturn(token); + + // when & then + mockMvc.perform(post("/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("VERIFIED_EMAIL_SUCCESS")) + .andExpect(jsonPath("$.result.resetToken").value(token)); + } + + @Test + @DisplayName("OTP 인증 실패 - 코드 불일치") + void verifyOtp_fail_codeNotMatch() throws Exception { + // given + String email = "test@example.com"; + String code = "WRONG"; + AuthRequest.VerifyOtpReq req = new AuthRequest.VerifyOtpReq(email, code); + + given(passwordResetService.verifyOtp(email, code)) + .willThrow(new AuthException(AuthErrorCode.CODE_NOT_MATCH_ERROR)); + + // when & then + mockMvc.perform(post("/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CODE_NOT_MATCH_ERROR")); + } + + @Test + @DisplayName("OTP 인증 실패 - 만료됨") + void verifyOtp_fail_expired() throws Exception { + // given + AuthRequest.VerifyOtpReq req = new AuthRequest.VerifyOtpReq("test@example.com", "ABC123"); + + given(passwordResetService.verifyOtp(anyString(), anyString())) + .willThrow(new AuthException(AuthErrorCode.EXPIRED_CODE_ERROR)); + + // when & then + mockMvc.perform(post("/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("EXPIRED_CODE_ERROR")); + } + + @Test + @DisplayName("비밀번호 재설정 성공") + void resetPassword_success() throws Exception { + // given + AuthRequest.ResetReq req = new AuthRequest.ResetReq("reset-token-123", "NewPassword1!"); + + doNothing().when(passwordResetService).resetPassword("reset-token-123", "NewPassword1!"); + + // when & then + mockMvc.perform(post("/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")); + } + + @Test + @DisplayName("비밀번호 재설정 실패 - 토큰 무효") + void resetPassword_fail_invalidToken() throws Exception { + // given + AuthRequest.ResetReq req = new AuthRequest.ResetReq("bad-token", "NewPassword1!"); + + doThrow(new AuthException(AuthErrorCode.RESET_TOKEN_INVALID)) + .when(passwordResetService).resetPassword("bad-token", "NewPassword1!"); + + // when & then + mockMvc.perform(post("/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("RESET_TOKEN_INVALID")); + } + + @Test + @DisplayName("비밀번호 재설정 실패 - 토큰 만료") + void resetPassword_fail_expiredToken() throws Exception { + // given + AuthRequest.ResetReq req = new AuthRequest.ResetReq("expired-token", "NewPassword1!"); + + doThrow(new AuthException(AuthErrorCode.RESET_TOKEN_EXPIRED)) + .when(passwordResetService).resetPassword("expired-token", "NewPassword1!"); + + // when & then + mockMvc.perform(post("/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("RESET_TOKEN_EXPIRED")); + } + // 테스트에서 인증 객체 설정을 위한 헬퍼 메서드 private static RequestPostProcessor authentication(Authentication authentication) { return SecurityMockMvcRequestPostProcessors.authentication(authentication); 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 new file mode 100644 index 0000000..6981352 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java @@ -0,0 +1,290 @@ +package com.project.InsightPrep.domain.auth.service; + +import com.project.InsightPrep.domain.auth.entity.PasswordVerification; +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.member.entity.Member; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import jakarta.mail.MessagingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +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 + SecurityUtil securityUtil; + + @InjectMocks PasswordResetServiceImpl service; + + private static final String EMAIL = "user@example.com"; + + @BeforeEach + void setUp() { + // no-op + } + + @Test + @DisplayName("requestOtp: 가입된 이메일이면 OTP 저장 및 메일 발송") + void requestOtp_sendsMail_whenEmailExists() throws Exception { + when(authMapper.existEmail(EMAIL)).thenReturn(true); + + // 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 + ); + verify(emailService).sendEmail(eq(EMAIL), anyString(), contains("비밀번호 재설정 인증 코드")); + verifyNoMoreInteractions(emailService, passwordMapper); + } + + @Test + @DisplayName("requestOtp: 미가입 이메일이면 아무 동작 없이 정상 종료 (정보 유출 방지)") + void requestOtp_returnsSilently_whenEmailNotExist() throws Exception { + when(authMapper.existEmail(EMAIL)).thenReturn(false); + + service.requestOtp(EMAIL); + + verifyNoInteractions(emailService); + verifyNoInteractions(passwordMapper); + } + + @Test + @DisplayName("requestOtp: 메일 전송 실패 시 RuntimeException 전파") + void requestOtp_throws_whenSendMailFails() throws Exception { + when(authMapper.existEmail(EMAIL)).thenReturn(true); + doThrow(new MessagingException("smtp down")) + .when(emailService).sendEmail(anyString(), anyString(), anyString()); + + assertThatThrownBy(() -> service.requestOtp(EMAIL)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("메일 전송 실패"); + } + + // ---------- verifyOtp ---------- + @Test + @DisplayName("verifyOtp: 정상 - 코드 일치 시 사용 처리 후 resetToken 발급 및 저장") + void verifyOtp_success_issueResetToken() { + PasswordVerification row = otpRow(false, // used + LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", // stored hash + 5, // attempts + false, // resetUsed + null, // resetExpiresAt + null // resetToken + ); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + 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)); + } + + @Test + @DisplayName("verifyOtp: 코드 불일치(남은 시도 > 0) → attempts 감소 후 예외") + void verifyOtp_decreaseAttempts_andThrow() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", 3, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + 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()); + } + + @Test + @DisplayName("verifyOtp: 코드 불일치(마지막 시도) → used 처리 후 OTP_INVALID") + void verifyOtp_lastAttempt_marksUsed_andThrows() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", 1, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(securityUtil.matches(eq("WRONG"), anyString())).thenReturn(false); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "WRONG")) + .isInstanceOf(AuthException.class); + + verify(passwordMapper).updateOtpAsUsed(EMAIL); + verify(passwordMapper, never()).updateAttempts(anyString(), anyInt()); + } + + @Test + @DisplayName("verifyOtp: 이미 사용된 OTP") + void verifyOtp_used_throws() { + PasswordVerification row = otpRow(true, LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", 5, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "ANY")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("verifyOtp: 만료된 OTP") + void verifyOtp_expired_throws() { + PasswordVerification row = otpRow(false, LocalDateTime.now().minusSeconds(1), + "$2a$10$hash", 5, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "ANY")) + .isInstanceOf(AuthException.class); + } + + // ---------- resetPassword ---------- + @Test + @DisplayName("resetPassword: 정상 - 토큰 유효, 비번 업데이트 및 토큰 사용 처리") + void resetPassword_success() { + String token = UUID.randomUUID().toString(); + PasswordVerification row = 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("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); + } + + @Test + @DisplayName("resetPassword: 토큰 조회 불가 → RESET_TOKEN_INVALID") + void resetPassword_tokenNotFound() { + when(passwordMapper.findByResetToken("bad")).thenReturn(null); + + assertThatThrownBy(() -> service.resetPassword("bad", "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 이미 사용된 토큰 → RESET_TOKEN_ALREADY_USED") + void resetPassword_tokenAlreadyUsed() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, true, LocalDateTime.now().plusMinutes(10), "token"); + when(passwordMapper.findByResetToken("token")).thenReturn(row); + + assertThatThrownBy(() -> service.resetPassword("token", "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 만료된 토큰 → RESET_TOKEN_EXPIRED") + void resetPassword_tokenExpired() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, false, LocalDateTime.now().minusSeconds(1), "token"); + when(passwordMapper.findByResetToken("token")).thenReturn(row); + + assertThatThrownBy(() -> service.resetPassword("token", "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 회원 없음 → MEMBER_NOT_FOUND") + void resetPassword_memberNotFound() { + String token = "token"; + PasswordVerification row = 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()); + + assertThatThrownBy(() -> service.resetPassword(token, "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 비밀번호 업데이트 실패 → SERVER_ERROR") + void resetPassword_updatePasswordFail() { + String token = "token"; + PasswordVerification row = 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(); + + 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); + + assertThatThrownBy(() -> service.resetPassword(token, "pw")) + .isInstanceOf(AuthException.class); + } + + private PasswordVerification otpRow(boolean used, + LocalDateTime otpExpiresAt, + String codeHash, + int attemptsLeft, + boolean resetUsed, + LocalDateTime resetExpiresAt, + String resetToken) { + PasswordVerification p = PasswordVerification.builder() + .email(EMAIL) + .used(used) + .expiresAt(otpExpiresAt) + .codeHash(codeHash) + .attemptsLeft(attemptsLeft) + .resetUsed(resetUsed) + .resetExpiresAt(resetExpiresAt) + .resetToken(resetToken) + .build(); + return p; + } +} \ 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 3893dbc..89c935b 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 @@ -1,15 +1,13 @@ package com.project.InsightPrep.domain.question.service.impl; -import static org.assertj.core.api.Assertions.assertThatCode; +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.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -17,6 +15,7 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; +import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; @@ -25,10 +24,11 @@ import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.global.auth.util.SecurityUtil; +import java.lang.reflect.Field; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InOrder; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -51,68 +51,63 @@ class AnswerServiceImplTest { @Mock private FeedbackServiceImpl feedbackService; - @Test @DisplayName("답변 저장 - 정상 동작") + @Test void saveAnswer_success() { // given Long questionId = 1L; - Member mockMember = Member.builder().id(1L).email("test@email.com").build(); + Member mockMember = Member.builder() + .id(1L) + .email("test@email.com") + .build(); + Question mockQuestion = Question.builder() .id(questionId) .content("질문 내용") .category("OS") .status(AnswerStatus.WAITING) .build(); + AnswerDto dto = new AnswerDto("테스트 답변입니다."); when(securityUtil.getAuthenticatedMember()).thenReturn(mockMember); when(questionMapper.findById(questionId)).thenReturn(mockQuestion); - // doNothing은 void 메서드에 대해 설정 - doNothing().when(questionMapper).updateStatus(eq(questionId), anyString()); + doNothing().when(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name())); + // insertAnswer 호출 시, DB가 생성한 PK가 들어간 것처럼 id 세팅을 시뮬레이션 doAnswer(invocation -> { - Answer answer = invocation.getArgument(0); - // answer.id 설정은 불필요 — 테스트 대상 아님 + Answer arg = invocation.getArgument(0); + Field idField = Answer.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(arg, 100L); return null; }).when(answerMapper).insertAnswer(any(Answer.class)); + + // feedback 호출 자체만 확인; 내용은 captor로 검증 doNothing().when(feedbackService).saveFeedback(any(Answer.class)); // when - answerService.saveAnswer(dto, questionId); + AnswerResponse.AnswerDto res = answerService.saveAnswer(dto, questionId); // then verify(securityUtil).getAuthenticatedMember(); verify(questionMapper).findById(questionId); - verify(questionMapper).updateStatus(eq(questionId), eq("ANSWERED")); + verify(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name())); verify(answerMapper).insertAnswer(any(Answer.class)); - verify(feedbackService).saveFeedback(any(Answer.class)); - } - - @Test - @DisplayName("성공: 내 답변 1건 삭제 → 피드백은 CASCADE, 남은 답변 없으면 질문 상태 WAITING으로") - void deleteAnswer_success() { - // given - long memberId = 1L; - long answerId = 100L; - long questionId = 10L; - when(securityUtil.getLoginMemberId()).thenReturn(memberId); - when(answerMapper.findQuestionIdOfMyAnswer(answerId, memberId)).thenReturn(questionId); - when(answerMapper.deleteMyAnswerById(answerId, memberId)).thenReturn(1); // 1건 삭제 - doNothing().when(answerMapper).resetQuestionStatusIfNoAnswers(questionId, AnswerStatus.WAITING.name()); - - // when & then - assertThatCode(() -> answerService.deleteAnswer(answerId)).doesNotThrowAnyException(); - - // verify: 순서 중요하면 InOrder로 - InOrder inOrder = inOrder(securityUtil, answerMapper); - inOrder.verify(securityUtil).getLoginMemberId(); - inOrder.verify(answerMapper).findQuestionIdOfMyAnswer(answerId, memberId); - inOrder.verify(answerMapper).deleteMyAnswerById(answerId, memberId); - inOrder.verify(answerMapper).resetQuestionStatusIfNoAnswers( - eq(questionId), eq(AnswerStatus.WAITING.name()) - ); - verifyNoMoreInteractions(answerMapper, securityUtil); + // feedbackService로 전달된 Answer에 id가 채워졌는지 검증 + ArgumentCaptor answerCaptor = ArgumentCaptor.forClass(Answer.class); + verify(feedbackService).saveFeedback(answerCaptor.capture()); + Answer savedForFeedback = answerCaptor.getValue(); + assertThat(savedForFeedback.getId()).isEqualTo(100L); + assertThat(savedForFeedback.getMember().getId()).isEqualTo(1L); + assertThat(savedForFeedback.getQuestion().getId()).isEqualTo(questionId); + assertThat(savedForFeedback.getContent()).isEqualTo("테스트 답변입니다."); + + // 반환 DTO 검증 (서비스가 DTO를 반환하도록 구현되어 있다는 가정) + assertThat(res).isNotNull(); + assertThat(res.getAnswerId()).isEqualTo(100L); + verifyNoMoreInteractions(securityUtil, questionMapper, answerMapper, feedbackService); } @Test 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 4352816..65405e9 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 @@ -5,12 +5,15 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; +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; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; 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; @@ -25,6 +28,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -87,10 +91,14 @@ void createQuestion_ShouldGenerateQuestionAndInsertIntoDatabase() { } @Test - @DisplayName("로그인 한 사용자의 답변 및 피드백, 질문 목록 조회") - void getQuestions() { - // given: 인증 로직 무시 → SecurityUtil만 스텁 + @DisplayName("페이지네이션: page=2, size=10 → offset=10, total=23 → totalPages=3") + void getQuestions_paged_ok() { + // given 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() @@ -98,16 +106,61 @@ void getQuestions() { .answerId(100L).answer("비교") .feedbackId(1000L).score(90).modelAnswer("...").build(); - when(answerMapper.findQuestionsWithFeedback(memberId)) + when(answerMapper.findQuestionsWithFeedbackPaged(memberId, size, offset)) .thenReturn(List.of(dto)); + when(answerMapper.countQuestionsWithFeedback(memberId)) + .thenReturn(23L); // 총 23건 → 10개씩이면 총 3페이지 // when - var result = questionService.getQuestions(); + PageResponse res = questionService.getQuestions(page, size); // then - assertThat(result).hasSize(1); - verify(securityUtil).getLoginMemberId(); // 인증 부분은 호출만 확인 - verify(answerMapper).findQuestionsWithFeedback(42L); // 핵심 상호작용 검증 + assertThat(res).isNotNull(); + 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(); + + InOrder inOrder = inOrder(securityUtil, answerMapper); + inOrder.verify(securityUtil).getLoginMemberId(); + inOrder.verify(answerMapper).findQuestionsWithFeedbackPaged(eq(memberId), eq(size), eq(offset)); + inOrder.verify(answerMapper).countQuestionsWithFeedback(eq(memberId)); verifyNoMoreInteractions(answerMapper, securityUtil); } + + @Test + @DisplayName("size 상한(최대 50) 적용: page=1, size=100 → limit=50, offset=0") + void getQuestions_sizeCappedTo50() { + // given + long memberId = 42L; + 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 + PageResponse res = questionService.getQuestions(page, requestedSize); + + // then + assertThat(res.getPage()).isEqualTo(1); + 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); + } } \ No newline at end of file