diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index ce509b24..8c84e601 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -55,7 +55,7 @@ public ResponseEntity login(@Valid @RequestBody MemberLoginReque } @PostMapping("/logout") - @Operation(summary = "08. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") + @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그아웃 성공") }) @@ -98,7 +98,7 @@ public ResponseEntity refreshToken(HttpServletRequest request, } @DeleteMapping("/withdraw") - @Operation(summary = "09. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.") + @Operation(summary = "10. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "회원탈퇴 성공"), @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), @@ -198,56 +198,37 @@ public ResponseEntity sendEmail( } @PostMapping("/verifyEmail") - @Operation(summary = "06. 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.") + @Operation(summary = "06. 인증번호 검증", description = "비로그인 사용자가 이메일로 받은 인증번호를 검증합니다. (비밀번호 재설정용)") @ApiResponses({ @ApiResponse(responseCode = "200", description = "인증번호 검증 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청 (인증번호 불일치, loginId 없음)") }) - public ResponseEntity verifyEmail( + public ResponseEntity verifyEmail( @RequestBody @Valid EmailVerifyCodeRequestDto requestDto, - Authentication authentication, - HttpServletRequest request) { - - String loginId = null; + Authentication authentication + ) { - // 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1) if (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal())) { - - // JWT 토큰에서 직접 loginid claim 추출 - try { - String token = extractAccessTokenFromRequest(request); - if (token != null) { - loginId = memberService.extractLoginIdFromToken(token); - if (loginId != null) { - log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId); - } else { - log.warn("JWT 토큰에서 loginId 추출 실패"); - } - } - } catch (Exception e) { - log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage()); - } + log.error("로그인된 사용자의 이메일 인증 시도"); + throw new IllegalArgumentException("로그인된 사용자는 비밀번호 검증을 사용해야 합니다."); } - // 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2) - if (loginId == null) { - if (requestDto.getLoginId() != null && !requestDto.getLoginId().isBlank()) { - loginId = requestDto.getLoginId(); - log.info("요청 바디에서 loginId 추출 성공: {}", loginId); - } else { - log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음"); - throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요."); - } + if (requestDto.getLoginId() == null || requestDto.getLoginId().isBlank()) { + log.error("요청 바디에 loginId가 없음"); + throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다."); } + String loginId = requestDto.getLoginId(); + log.info("이메일 인증번호 검증 요청: {}", loginId); + try { // 서비스 호출 - 인증번호 검증 boolean isValid = memberService.verifyAuthCode(loginId, requestDto.getVerificationCode()); if (isValid) { log.info("이메일 인증번호 검증 성공: {}", loginId); - return ResponseEntity.ok(EmailResponse.success("인증번호 검증 성공", loginId)); + return ResponseEntity.ok(VerificationResponse.success("인증번호 검증 성공", loginId)); } else { log.error("이메일 인증번호 검증 실패 - 잘못된 인증번호: {}", loginId); throw new IllegalArgumentException("잘못된 인증번호이거나 만료된 인증번호입니다."); @@ -262,10 +243,63 @@ public ResponseEntity verifyEmail( } } - // ===== 비밀번호 재설정 엔드포인트 ===== + @PostMapping("/verifyPassword") + @Operation(summary = "07. 비밀번호 검증", description = "로그인된 사용자가 비밀번호를 통해 인증합니다. (비밀번호 재설정용)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "비밀번호 검증 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (비밀번호 불일치)"), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자") + }) + public ResponseEntity verifyPassword( + @RequestBody @Valid PasswordVerifyRequestDto requestDto, + Authentication authentication, + HttpServletRequest request){ + + if (authentication == null || !authentication.isAuthenticated() || + "anonymousUser".equals(authentication.getPrincipal())) { + log.error("비로그인 사용자의 비밀번호 검증 시도"); + throw new IllegalArgumentException("비밀번호 검증은 로그인된 사용자만 가능합니다. 비로그인 사용자는 이메일 인증을 사용하세요."); + } + + String loginId = null; + try { + String token = extractAccessTokenFromRequest(request); + if (token != null) { + loginId = memberService.extractLoginIdFromToken(token); + } + } catch (Exception e) { + log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage()); + } + + if (loginId == null) { + log.error("JWT 토큰에서 loginId 추출 실패"); + throw new IllegalArgumentException("유효하지 않은 토큰입니다."); + } + + log.info("비밀번호 검증 요청: {}", loginId); + + try { + boolean isValid = memberService.verifyPassword(loginId, requestDto.getPassword()); + + if (isValid) { + log.info("비밀번호 검증 성공: {}", loginId); + return ResponseEntity.ok(VerificationResponse.success("비밀번호 검증 성공", loginId)); + } else { + log.error("비밀번호 검증 실패 - 비밀번호 불일치: {}", loginId); + throw new IllegalArgumentException("잘못된 입력입니다."); + } + + } catch (IllegalArgumentException e) { + log.error("비밀번호 검증 실패: loginId={}, error={}", loginId, e.getMessage()); + throw e; + } catch (Exception e) { + log.error("비밀번호 검증 중 오류 발생: loginId={}, error={}", loginId, e.getMessage()); + throw new RuntimeException("비밀번호 검증 중 오류가 발생했습니다."); + } + } @PostMapping("/password-reset/reset") - @Operation(summary = "07. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.") + @Operation(summary = "08. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"), @ApiResponse(responseCode = "400", description = "인증되지 않았거나 잘못된 요청") @@ -285,7 +319,6 @@ public ResponseEntity resetPassword( String loginId = null; - // 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1) if (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal())) { @@ -341,7 +374,8 @@ private String extractRefreshTokenFromCookies(HttpServletRequest request) { } /** - * HTTP 쿠키에서 액세스 토큰을 추출합니다. + * HTTP 요청에서 액세스 토큰을 추출합니다. + * Authorization 헤더 또는 쿠키에서 토큰을 확인합니다. * @param request HTTP 요청 객체 * @return 액세스 토큰 값 또는 null */ diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/EmailVerifyCodeRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/EmailVerifyCodeRequestDto.java index a4bc4c95..91c74e34 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/dto/EmailVerifyCodeRequestDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/EmailVerifyCodeRequestDto.java @@ -8,7 +8,6 @@ @Getter @Setter public class EmailVerifyCodeRequestDto { - // 선택적 필드 - JWT 토큰이 있으면 불필요, 없으면 필수 private String loginId; @NotBlank(message = "인증번호를 입력해주세요.") diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/PasswordVerifyRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/PasswordVerifyRequestDto.java new file mode 100644 index 00000000..9ba68c80 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/PasswordVerifyRequestDto.java @@ -0,0 +1,10 @@ +package com.ai.lawyer.domain.member.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PasswordVerifyRequestDto { + String password; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/VerificationResponse.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/VerificationResponse.java new file mode 100644 index 00000000..757d635b --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/VerificationResponse.java @@ -0,0 +1,26 @@ +package com.ai.lawyer.domain.member.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VerificationResponse { + private String message; + private String email; + private LocalDateTime timestamp; + private boolean success; + + public static VerificationResponse success(String message, String email) { + return VerificationResponse.builder() + .message(message) + .email(email) + .success(true) + .timestamp(LocalDateTime.now()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java index 3d512254..630aa4f3 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java @@ -30,7 +30,7 @@ public class Member { @Column(name = "loginid", nullable = false, unique = true, length = 100) @Email(message = "올바른 이메일 형식이 아닙니다") @NotBlank(message = "이메일(로그인 ID)은 필수입니다") - private String loginId; // 반드시 이메일 형식 + private String loginId; @Column(name = "password", nullable = false) @NotBlank(message = "비밀번호는 필수입니다") @@ -60,10 +60,9 @@ public class Member { private LocalDateTime createdAt; @UpdateTimestamp - @Column(name = "updated_at") // nullable = true (유일하게 null 허용) + @Column(name = "updated_at") private LocalDateTime updatedAt; - // Enums @Getter public enum Gender { MALE("남성"), FEMALE("여성"), OTHER("기타"); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 69140325..7a35feb9 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -40,7 +40,6 @@ public MemberResponse signup(MemberSignupRequest request, HttpServletResponse re Member savedMember = memberRepository.save(member); - // 회원가입 후 자동 로그인: JWT 토큰 생성 및 쿠키 설정 String accessToken = tokenProvider.generateAccessToken(savedMember); String refreshToken = tokenProvider.generateRefreshToken(savedMember); cookieUtil.setTokenCookies(response, accessToken, refreshToken); @@ -56,7 +55,6 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); } - // JWT 액세스 토큰과 리프레시 토큰 생성 후 HTTP 쿠키에 설정 String accessToken = tokenProvider.generateAccessToken(member); String refreshToken = tokenProvider.generateRefreshToken(member); cookieUtil.setTokenCookies(response, accessToken, refreshToken); @@ -65,39 +63,31 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp } public void logout(String loginId, HttpServletResponse response) { - // 로그인 ID가 존재할 경우 Redis에서 모든 토큰 삭제 if (loginId != null && !loginId.trim().isEmpty()) { tokenProvider.deleteAllTokens(loginId); } - // 인증 상태와 관계없이 클라이언트 쿠키 클리어 cookieUtil.clearTokenCookies(response); } public MemberResponse refreshToken(String refreshToken, HttpServletResponse response) { - // Redis에서 리프레시 토큰으로 사용자 찾기 String loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); if (loginId == null) { throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } - // 리프레시 토큰 유효성 검증 if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } - // 회원 정보 조회 Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제 tokenProvider.deleteAllTokens(loginId); - // 새로운 액세스 토큰과 리프레시 토큰 생성 String newAccessToken = tokenProvider.generateAccessToken(member); String newRefreshToken = tokenProvider.generateRefreshToken(member); - // 새로운 토큰들을 HTTP 쿠키에 설정 cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); return MemberResponse.from(member); @@ -121,70 +111,53 @@ public MemberResponse getMemberById(Long memberId) { public void sendCodeToEmailByLoginId(String loginId) { Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("해당 로그인 ID의 회원이 없습니다.")); - String email = member.getLoginId(); // loginId가 이메일이므로 바로 사용 - emailService.sendVerificationCode(email, loginId); // Redis에 저장 + 메일 전송 + String email = member.getLoginId(); + emailService.sendVerificationCode(email, loginId); } - /** - * 이메일 인증번호 검증 (일반 용도) - */ public boolean verifyAuthCode(String loginId, String verificationCode) { - // 회원 존재 여부 확인 memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 인증번호 검증 return emailAuthService.verifyAuthCode(loginId, verificationCode); } + public boolean verifyPassword(String loginId, String password) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + return passwordEncoder.matches(password, member.getPassword()); + } - /** - * 비밀번호 재설정 실행 - */ @Transactional public void resetPassword(String loginId, String newPassword, Boolean success) { - // 회원 존재 여부 확인 Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 클라이언트에서 전달한 success 값과 Redis의 인증 성공 여부를 모두 확인 boolean clientSuccess = Boolean.TRUE.equals(success); - // 클라이언트 success가 false면 바로 실패 if (!clientSuccess) { throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); } - // 클라이언트 success가 true면 Redis 인증 상태도 확인 boolean redisVerified = emailAuthService.isEmailVerified(loginId); if (!redisVerified) { throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); } - // 비밀번호 변경 String encodedPassword = passwordEncoder.encode(newPassword); member.updatePassword(encodedPassword); memberRepository.save(member); - // 인증 데이터 삭제 (비밀번호 재설정 완료 후) emailAuthService.clearAuthData(loginId); - // 기존 모든 토큰 삭제 (보안상 로그아웃 처리) tokenProvider.deleteAllTokens(loginId); } - /** - * JWT 토큰에서 loginId 추출 - */ public String extractLoginIdFromToken(String token) { return tokenProvider.getLoginIdFromToken(token); } - /** - * 로그인 ID 중복 검사 - * @param loginId 검사할 로그인 ID - * @throws IllegalArgumentException 중복된 로그인 ID인 경우 - */ private void validateDuplicateLoginId(String loginId) { if (memberRepository.existsByLoginId(loginId)) { throw new IllegalArgumentException("이미 존재하는 이메일입니다."); diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index f2bb02b4..4f7c6075 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java @@ -2,10 +2,10 @@ import com.ai.lawyer.domain.poll.dto.PollCreateDto; import com.ai.lawyer.domain.poll.dto.PollDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import com.ai.lawyer.domain.poll.dto.PollVoteDto; import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.poll.entity.PollOptions; -import com.ai.lawyer.domain.poll.entity.PollStatics; import com.ai.lawyer.domain.poll.service.PollService; import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.service.PostService; @@ -54,10 +54,10 @@ public ResponseEntity> vote(@PathVariable Long pollId, return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result)); } - @Operation(summary = "투표 통계 조회") + @Operation(summary = "투표 통계 조회 (항목별 나이/성별 카운트)") @GetMapping("/{pollId}/statics") - public ResponseEntity>> getPollStatics(@PathVariable Long pollId) { - List statics = pollService.getPollStatics(pollId); + public ResponseEntity> getPollStatics(@PathVariable Long pollId) { + PollStaticsResponseDto statics = pollService.getPollStatics(pollId); return ResponseEntity.ok(new ApiResponse<>(200, "투표 통계 조회 성공", statics)); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java new file mode 100644 index 00000000..a79fd02e --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java @@ -0,0 +1,22 @@ +package com.ai.lawyer.domain.poll.dto; +import lombok.*; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollAgeStaticsDto { + private Long pollItemsId; + private Integer pollOptionIndex; + private java.util.List ageGroupCounts; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class AgeGroupCountDto { + private String option; + private String ageGroup; + private Long voteCount; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java new file mode 100644 index 00000000..4a902e33 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java @@ -0,0 +1,21 @@ +package com.ai.lawyer.domain.poll.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.util.List; +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PollForPostDto { + @Schema(description = "투표 제목", example = "당신의 선택은?") + private String voteTitle; + @Schema(description = "투표 항목(2개 필수)", example = "[{\"content\": \"항목1 내용\"}, {\"content\": \"항목2 내용\"}]") + private List pollOptions; + private LocalDateTime reservedCloseAt; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java new file mode 100644 index 00000000..c2bc4297 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java @@ -0,0 +1,22 @@ +package com.ai.lawyer.domain.poll.dto; +import lombok.*; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollGenderStaticsDto { + private Long pollItemsId; + private Integer pollOptionIndex; + private java.util.List genderCounts; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class GenderCountDto { + private String option; + private String gender; + private Long voteCount; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java new file mode 100644 index 00000000..a932eed3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java @@ -0,0 +1,15 @@ +package com.ai.lawyer.domain.poll.dto; + +import lombok.*; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollStaticsResponseDto { + private Long postId; + private Long pollId; + private List optionAgeStatics; + private List optionGenderStatics; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java deleted file mode 100644 index 0f6f31c5..00000000 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ai.lawyer.domain.poll.dto; - -import com.ai.lawyer.domain.post.dto.PostDto; -import lombok.*; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class PollWithPostDto { - private PollDto poll; - private PostDto post; -} - diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java index 35c0ed57..50ee6d37 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java @@ -8,3 +8,4 @@ public interface PollStaticsRepository extends JpaRepository { List findByPoll_PollId(Long pollId); } + diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index 8f976d8c..712f68c1 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -25,4 +25,29 @@ public interface PollVoteRepository extends JpaRepository { java.util.List countStaticsByPollOptionIds(@Param("pollOptionIds") java.util.List pollOptionIds); boolean existsByPoll_PollIdAndMember_MemberId(Long pollId, Long memberId); + + @Query(value = "SELECT po.option, m.gender, COUNT(*) FROM poll_vote pv JOIN poll_options po ON pv.poll_items_id = po.poll_items_id JOIN member m ON pv.member_id = m.member_id WHERE po.poll_id = :pollId GROUP BY po.option, m.gender", nativeQuery = true) + List getGenderOptionStatics(@Param("pollId") Long pollId); + + @Query(value = "SELECT CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + + "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + + "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' " + + "END AS ageGroup, m.gender, COUNT(*) FROM poll_vote pv JOIN member m ON pv.member_id = m.member_id JOIN poll_options po ON pv.poll_items_id = po.poll_items_id WHERE po.poll_id = :pollId GROUP BY ageGroup, m.gender", nativeQuery = true) + List getAgeGenderStatics(@Param("pollId") Long pollId); + + @Query("SELECT o.option, " + + "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + + "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + + "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END, " + + "COUNT(v) " + + "FROM PollVote v JOIN v.pollOptions o JOIN v.member m " + + "WHERE o.poll.pollId = :pollId " + + "GROUP BY o.option, " + + "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + + "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + + "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END") + List getOptionAgeStatics(@Param("pollId") Long pollId); + + @Query("SELECT o.option, m.gender, COUNT(v) FROM PollVote v JOIN v.pollOptions o JOIN v.member m WHERE o.poll.pollId = :pollId GROUP BY o.option, m.gender") + List getOptionGenderStatics(@Param("pollId") Long pollId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 92c7592f..95b230c4 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -7,23 +7,36 @@ import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.poll.entity.PollStatics; import com.ai.lawyer.domain.poll.entity.PollOptions; +import com.ai.lawyer.domain.poll.dto.PollForPostDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import java.util.List; public interface PollService { + // ===== 조회 관련 ===== PollDto getPoll(Long pollId); + PollDto getPollWithStatistics(Long pollId); List getPollOptions(Long pollId); - PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); - List getPollStatics(Long pollId); - void closePoll(Long pollId); - void deletePoll(Long pollId); + List getPollsByStatus(PollDto.PollStatus status); PollDto getTopPollByStatus(PollDto.PollStatus status); + List getTopNPollsByStatus(PollDto.PollStatus status, int n); + + // ===== 통계 관련 ===== + PollStaticsResponseDto getPollStatics(Long pollId); Long getVoteCountByPollId(Long pollId); Long getVoteCountByPostId(Long postId); - PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto); - PollDto getPollWithStatistics(Long pollId); + + // ===== 투표 관련 ===== + PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); + + // ===== 생성/수정/삭제 관련 ===== PollDto createPoll(PollCreateDto request, Long memberId); + PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto); void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto); - List getPollsByStatus(PollDto.PollStatus status); - List getTopNPollsByStatus(PollDto.PollStatus status, int n); + void closePoll(Long pollId); + void deletePoll(Long pollId); + + // ===== 검증 관련 ===== + void validatePollCreate(PollCreateDto dto); + void validatePollCreate(PollForPostDto dto); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index b21002c9..feb876b0 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -2,7 +2,6 @@ import com.ai.lawyer.domain.poll.entity.*; import com.ai.lawyer.domain.poll.repository.*; -import com.ai.lawyer.domain.poll.service.*; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; @@ -17,6 +16,7 @@ import java.util.List; import java.util.ArrayList; import com.ai.lawyer.domain.poll.dto.PollCreateDto; +import com.ai.lawyer.domain.poll.dto.PollForPostDto; import com.ai.lawyer.domain.poll.dto.PollOptionCreateDto; import com.ai.lawyer.domain.poll.dto.PollStaticsDto; import com.ai.lawyer.domain.poll.dto.PollOptionDto; @@ -27,6 +27,10 @@ import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; +import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; +import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto; + @Service @Transactional @RequiredArgsConstructor @@ -42,44 +46,24 @@ public class PollServiceImpl implements PollService { @Override public PollDto createPoll(PollCreateDto request, Long memberId) { if (request.getPostId() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID(postId)는 필수입니다."); - } - if (request.getVoteTitle() == null || request.getVoteTitle().trim().isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 제목(voteTitle)은 필수입니다."); - } - if (request.getPollOptions() == null || request.getPollOptions().size() != 2) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 항목은 2개여야 합니다."); - } - for (PollOptionCreateDto option : request.getPollOptions()) { - if (option.getContent() == null || option.getContent().trim().isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "각 투표 항목의 내용(content)은 필수입니다."); - } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID는 필수입니다."); } + validatePollCommon(request.getVoteTitle(), request.getPollOptions(), request.getReservedCloseAt()); Member member = memberRepository.findById(memberId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); Post post = postRepository.findById(request.getPostId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - // 이미 해당 게시글에 투표가 존재하는 경우 예외 처리 if (post.getPoll() != null) { throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 해당 게시글에 투표가 존재합니다."); } try { LocalDateTime now = java.time.LocalDateTime.now(); - LocalDateTime reservedCloseAt = request.getReservedCloseAt(); - if (reservedCloseAt != null) { - if (reservedCloseAt.isBefore(now.plusHours(1))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 현재로부터 최소 1시간 이후여야 합니다."); - } - if (reservedCloseAt.isAfter(now.plusDays(7))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다."); - } - } Poll poll = Poll.builder() .post(post) .voteTitle(request.getVoteTitle()) .status(Poll.PollStatus.ONGOING) .createdAt(now) - .reservedCloseAt(reservedCloseAt) + .reservedCloseAt(request.getReservedCloseAt()) .build(); Poll savedPoll = pollRepository.save(poll); post.setPoll(savedPoll); @@ -119,7 +103,9 @@ public List getPollsByStatus(PollDto.PollStatus status) { } List pollDtos = polls.stream() .filter(p -> p.getStatus().name().equals(status.name())) - .map(this::convertToDto) + .map(p -> status == PollDto.PollStatus.CLOSED + ? getPollWithStatistics(p.getPollId()) + : convertToDto(p)) .toList(); return pollDtos; } @@ -163,12 +149,72 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { } @Override - public List getPollStatics(Long pollId) { - // 투표 존재 여부 체크 + public PollStaticsResponseDto getPollStatics(Long pollId) { if (!pollRepository.existsById(pollId)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 투표가 존재하지 않습니다."); } - return pollStaticsRepository.findByPoll_PollId(pollId); + Poll poll = pollRepository.findById(pollId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); + Long postId = poll.getPost() != null ? poll.getPost().getPostId() : null; + List options = pollOptionsRepository.findByPoll_PollId(pollId); + java.util.Map optionMap = new java.util.HashMap<>(); + for (int i = 0; i < options.size(); i++) { + PollOptions opt = options.get(i); + optionMap.put(opt.getOption(), opt); + } + // age 통계 그룹핑 + List optionAgeRaw = pollVoteRepository.getOptionAgeStatics(pollId); + java.util.Map> ageGroupMap = new java.util.HashMap<>(); + for (Object[] arr : optionAgeRaw) { + String option = arr[0] != null ? arr[0].toString() : null; + PollOptions opt = optionMap.get(option); + if (opt == null) continue; + Long pollItemsId = opt.getPollItemsId(); + PollAgeStaticsDto.AgeGroupCountDto dto = PollAgeStaticsDto.AgeGroupCountDto.builder() + .option(option) + .ageGroup(arr[1] != null ? arr[1].toString() : null) + .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .build(); + ageGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); + } + java.util.List optionAgeStatics = new java.util.ArrayList<>(); + for (int i = 0; i < options.size(); i++) { + PollOptions opt = options.get(i); + optionAgeStatics.add(PollAgeStaticsDto.builder() + .pollItemsId(opt.getPollItemsId()) + .pollOptionIndex(i + 1) + .ageGroupCounts(ageGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) + .build()); + } + // gender 통계 그룹핑 + List optionGenderRaw = pollVoteRepository.getOptionGenderStatics(pollId); + java.util.Map> genderGroupMap = new java.util.HashMap<>(); + for (Object[] arr : optionGenderRaw) { + String option = arr[0] != null ? arr[0].toString() : null; + PollOptions opt = optionMap.get(option); + if (opt == null) continue; + Long pollItemsId = opt.getPollItemsId(); + PollGenderStaticsDto.GenderCountDto dto = PollGenderStaticsDto.GenderCountDto.builder() + .option(option) + .gender(arr[1] != null ? arr[1].toString() : null) + .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .build(); + genderGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); + } + java.util.List optionGenderStatics = new java.util.ArrayList<>(); + for (int i = 0; i < options.size(); i++) { + PollOptions opt = options.get(i); + optionGenderStatics.add(PollGenderStaticsDto.builder() + .pollItemsId(opt.getPollItemsId()) + .pollOptionIndex(i + 1) + .genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) + .build()); + } + return PollStaticsResponseDto.builder() + .postId(postId) + .pollId(pollId) + .optionAgeStatics(optionAgeStatics) + .optionGenderStatics(optionGenderStatics) + .build(); } // 최대 7일 동안 투표 가능 (초기 요구사항) @@ -228,7 +274,9 @@ public List getTopNPollsByStatus(PollDto.PollStatus status, int n) { List pollDtos = new java.util.ArrayList<>(); for (Object[] row : result) { Long pollId = (Long) row[0]; - pollDtos.add(getPoll(pollId)); + pollDtos.add(status == PollDto.PollStatus.CLOSED + ? getPollWithStatistics(pollId) + : getPoll(pollId)); } return pollDtos; } @@ -487,4 +535,37 @@ private void autoCloseIfNeeded(Poll poll) { public List getPollOptions(Long pollId) { return pollOptionsRepository.findByPoll_PollId(pollId); } + + private static void validatePollCommon(String voteTitle, java.util.List options, java.time.LocalDateTime reservedCloseAt) { + if (voteTitle == null || voteTitle.trim().isEmpty()) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 제목은 필수입니다."); + } + if (options == null || options.size() != 2) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 항목은 2개여야 합니다."); + } + for (com.ai.lawyer.domain.poll.dto.PollOptionCreateDto option : options) { + if (option.getContent() == null || option.getContent().trim().isEmpty()) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "각 투표 항목의 내용은 필수입니다."); + } + } + java.time.LocalDateTime now = java.time.LocalDateTime.now(); + if (reservedCloseAt != null) { + if (reservedCloseAt.isBefore(now.plusHours(1))) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "예약 종료 시간은 현재로부터 최소 1시간 이후여야 합니다."); + } + if (reservedCloseAt.isAfter(now.plusDays(7))) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다."); + } + } + } + + @Override + public void validatePollCreate(PollForPostDto dto) { + validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); + } + + @Override + public void validatePollCreate(PollCreateDto dto) { + validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); + } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java index 738d0275..fc8a74e6 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java @@ -4,7 +4,9 @@ import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.service.PostService; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.jwt.TokenProvider; @@ -69,6 +71,13 @@ public ResponseEntity>> getAllPosts() { return ResponseEntity.ok(new ApiResponse<>(200, "게시글 전체 조회 성공", posts)); } + @Operation(summary = "게시글 간편 전체 조회") + @GetMapping("/simple") + public ResponseEntity>> getAllSimplePosts() { + List posts = postService.getAllSimplePosts(); + return ResponseEntity.ok(new ApiResponse<>(200, "게시글 간편 전체 조회 성공", posts)); + } + @Operation(summary = "게시글 단일 조회") @GetMapping("/{postId}") public ResponseEntity> getPostById(@PathVariable Long postId) { @@ -126,7 +135,7 @@ public ResponseEntity> getMyPostById(@PathVariable Long pos } else if (principal instanceof Long) { memberId = (Long) principal; } else { - throw new IllegalArgumentException("principal이 올바른 회원 ID가 아닙니다"); + throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); } PostDto postDto = postService.getMyPostById(postId, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 단일 조회 성공", postDto)); @@ -143,9 +152,26 @@ public ResponseEntity>> getMyPosts() { } else if (principal instanceof Long) { memberId = (Long) principal; } else { - throw new IllegalArgumentException("principal이 올바른 회원 ID가 아닙니다"); + throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); } List posts = postService.getMyPosts(memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 전체 조회 성공", posts)); } + + @Operation(summary = "게시글+투표 동시 등록") + @PostMapping("/with-poll") + public ResponseEntity> createPostWithPoll(@RequestBody PostWithPollCreateDto dto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Object principal = authentication.getPrincipal(); + Long memberId; + if (principal instanceof org.springframework.security.core.userdetails.User user) { + memberId = Long.valueOf(user.getUsername()); + } else if (principal instanceof Long) { + memberId = (Long) principal; + } else { + throw new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "인증 정보가 올바르지 않습니다."); + } + PostDetailDto result = postService.createPostWithPoll(dto, memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "게시글+투표 등록 완료", result)); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java new file mode 100644 index 00000000..966c2869 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java @@ -0,0 +1,20 @@ +package com.ai.lawyer.domain.post.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PostSimpleDto { + private Long postId; + private Long memberId; + private PollInfo poll; + + @Data + @Builder + public static class PollInfo { + private Long pollId; + private String pollStatus; + } +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java new file mode 100644 index 00000000..569b3658 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java @@ -0,0 +1,13 @@ +package com.ai.lawyer.domain.post.dto; + +import com.ai.lawyer.domain.poll.dto.PollForPostDto; +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PostWithPollCreateDto { + private PostRequestDto post; + private PollForPostDto poll; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index a936ce18..67d88f05 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java @@ -5,28 +5,27 @@ import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; import java.util.List; public interface PostService { - - PostDto createPost(PostRequestDto postRequestDto, Long memberId); - + // ===== 조회 관련 ===== PostDetailDto getPostById(Long postId); - PostDetailDto getPostDetailById(Long postId); - + List getAllPosts(); + List getAllSimplePosts(); List getPostsByMemberId(Long memberId); + // ===== 생성/수정/삭제 관련 ===== + PostDto createPost(PostRequestDto postRequestDto, Long memberId); PostDto updatePost(Long postId, PostUpdateDto postUpdateDto); - + void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); void deletePost(Long postId); + PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId); - List getAllPosts(); - + // ===== 본인 게시글 관련 ===== PostDto getMyPostById(Long postId, Long requesterMemberId); - List getMyPosts(Long requesterMemberId); - - void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index 5bafa6e4..a2fe1022 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -6,10 +6,13 @@ import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; import com.ai.lawyer.domain.poll.repository.PollRepository; import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.poll.dto.PollCreateDto; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.poll.dto.PollUpdateDto; import com.ai.lawyer.domain.poll.repository.PollOptionsRepository; @@ -18,6 +21,7 @@ import com.ai.lawyer.domain.poll.service.PollService; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; @@ -45,6 +49,10 @@ public PostServiceImpl(PostRepository postRepository, MemberRepository memberRep @Override public PostDto createPost(PostRequestDto postRequestDto, Long memberId) { + if (postRequestDto.getPostName() == null || postRequestDto.getPostName().trim().isEmpty() || + postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); + } Member member = memberRepository.findById(memberId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); Post post = Post.builder() @@ -178,6 +186,67 @@ public void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto) { postRepository.save(post); } + @Override + @Transactional + public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId) { + PostRequestDto postDto = dto.getPost(); + if (postDto == null || postDto.getPostName() == null || postDto.getPostName().trim().isEmpty() || + postDto.getPostContent() == null || postDto.getPostContent().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); + } + var pollDto = dto.getPoll(); + pollService.validatePollCreate(pollDto); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Post post = Post.builder() + .member(member) + .postName(postDto.getPostName()) + .postContent(postDto.getPostContent()) + .category(postDto.getCategory()) + .createdAt(LocalDateTime.now()) + .build(); + Post savedPost = postRepository.save(post); + Poll poll = Poll.builder() + .voteTitle(pollDto.getVoteTitle()) + .reservedCloseAt(pollDto.getReservedCloseAt()) + .createdAt(LocalDateTime.now()) + .status(Poll.PollStatus.ONGOING) + .post(savedPost) + .build(); + Poll savedPoll = pollRepository.save(poll); + for (var optionDto : pollDto.getPollOptions()) { + PollOptions option = PollOptions.builder() + .poll(savedPoll) + .option(optionDto.getContent()) + .build(); + pollOptionsRepository.save(option); + } + savedPost.setPoll(savedPoll); + postRepository.save(savedPost); + return getPostDetailById(savedPost.getPostId()); + } + + @Override + public List getAllSimplePosts() { + List posts = postRepository.findAll(); + return posts.stream() + .map(post -> { + PostSimpleDto.PollInfo pollInfo = null; + if (post.getPoll() != null) { + pollInfo = PostSimpleDto.PollInfo.builder() + .pollId(post.getPoll().getPollId()) + .pollStatus(post.getPoll().getStatus().name()) + .build(); + } + return PostSimpleDto.builder() + .postId(post.getPostId()) + .memberId(post.getMember().getMemberId()) + .poll(pollInfo) + .build(); + }) + .collect(Collectors.toList()); + } + private PostDto convertToDto(Post entity) { Long memberId = null; if (entity.getMember() != null) { diff --git a/backend/src/main/java/com/ai/lawyer/global/config/EmailConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/EmailConfig.java deleted file mode 100644 index 1f467791..00000000 --- a/backend/src/main/java/com/ai/lawyer/global/config/EmailConfig.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.ai.lawyer.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.JavaMailSenderImpl; - -import java.util.Properties; - -@Configuration -public class EmailConfig { - - @Value("${spring.mail.host}") - private String host; - - @Value("${spring.mail.port}") - private int port; - - @Value("${spring.mail.username}") - private String username; - - @Value("${spring.mail.password}") - private String password; - - @Value("${spring.mail.properties.mail.smtp.auth}") - private boolean auth; - - @Value("${spring.mail.properties.mail.smtp.starttls.enable}") - private boolean starttlsEnable; - - @Value("${spring.mail.properties.mail.smtp.starttls.required}") - private boolean starttlsRequired; - - @Value("${spring.mail.properties.mail.smtp.connectiontimeout}") - private int connectionTimeout; - - @Value("${spring.mail.properties.mail.smtp.timeout}") - private int timeout; - - @Value("${spring.mail.properties.mail.smtp.writetimeout}") - private int writeTimeout; - - @Value("${spring.mail.from:no-reply@trybalaw.com}") - private String fromAddress; - - @Bean - public JavaMailSender javaMailSender() { - JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - mailSender.setHost(host); - mailSender.setPort(port); - mailSender.setUsername(username); - mailSender.setPassword(password); - mailSender.setDefaultEncoding("UTF-8"); - - // 기본 발신자 설정: 인증 전용 no-reply 이메일 - // Gmail SMTP의 제약을 우회하기 위한 다양한 설정 시도 - Properties properties = getMailProperties(); - - // 발신자 관련 모든 속성에 no-reply 주소 설정 - properties.put("mail.smtp.from", fromAddress); - properties.put("mail.from", fromAddress); - properties.put("mail.smtp.envelope.from", fromAddress); - properties.put("mail.mime.address.strict", "false"); - properties.put("mail.smtp.dsn.notify", "NEVER"); - - mailSender.setJavaMailProperties(properties); - - return mailSender; - } - - private Properties getMailProperties() { - Properties properties = new Properties(); - properties.put("mail.smtp.auth", auth); - properties.put("mail.smtp.starttls.enable", starttlsEnable); - properties.put("mail.smtp.starttls.required", starttlsRequired); - properties.put("mail.smtp.connectiontimeout", connectionTimeout); - properties.put("mail.smtp.timeout", timeout); - properties.put("mail.smtp.writetimeout", writeTimeout); - - // 발신자 관련 추가 설정 - properties.put("mail.smtp.from", fromAddress); - properties.put("mail.from", fromAddress); - properties.put("mail.smtp.envelope.from", fromAddress); - - // Gmail의 발신자 변경 방지 시도 - properties.put("mail.smtp.allow8bitmime", true); - properties.put("mail.smtp.sendpartial", true); - - return properties; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/email/service/EmailService.java b/backend/src/main/java/com/ai/lawyer/global/email/service/EmailService.java index bf09a1a6..a20cf3d8 100644 --- a/backend/src/main/java/com/ai/lawyer/global/email/service/EmailService.java +++ b/backend/src/main/java/com/ai/lawyer/global/email/service/EmailService.java @@ -48,10 +48,10 @@ public void sendEmail(String toEmail, String title, String content) throws Messa helper.setSubject(title); helper.setText(content, true); - helper.setFrom(fromAddress, "TrybaLaw 인증시스템"); + helper.setFrom(fromAddress, "BaLaw 이메일 인증"); helper.setReplyTo(fromAddress); - log.info("발신자: {} (표시 이름: TrybaLaw 인증시스템)", fromAddress); + log.info("발신자: {} (표시 이름: BaLaw 이메일 인증)", fromAddress); emailSender.send(message); } diff --git a/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java b/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java new file mode 100644 index 00000000..4d9392c2 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java @@ -0,0 +1,52 @@ +package com.ai.lawyer.global.initData; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@Order(1) +@Profile("!test") +@RequiredArgsConstructor +public class InitData implements ApplicationRunner { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public void run(ApplicationArguments args) { + makeAdmin(); + } + + @Transactional + void makeAdmin() { + long count = memberRepository.count(); + if (count > 0) { + log.info("InitData skipped: users count={}", count); + return; + } + + Member member = memberRepository.save( + Member.builder() + .loginId("admin@example.com") + .password(passwordEncoder.encode("1234")) + .age(30) + .gender(Member.Gender.FEMALE) + .name("admin") + .role(Member.Role.USER) + .build() + ); + + log.warn("=== Admin user created: {} ===", member.getLoginId()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java index 400fcbd2..10d7a642 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java @@ -21,7 +21,7 @@ public void setTokenCookies(HttpServletResponse response, String accessToken, St public void setAccessTokenCookie(HttpServletResponse response, String accessToken) { Cookie accessCookie = new Cookie(ACCESS_TOKEN_NAME, accessToken); accessCookie.setHttpOnly(true); - accessCookie.setSecure(false); // TODO: 운영환경에서는 true로 변경 (HTTPS) + accessCookie.setSecure(false); // 운영환경에서는 true로 변경 (HTTPS) accessCookie.setPath("/"); accessCookie.setMaxAge(ACCESS_TOKEN_EXPIRE_TIME); response.addCookie(accessCookie); @@ -30,7 +30,7 @@ public void setAccessTokenCookie(HttpServletResponse response, String accessToke public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { Cookie refreshCookie = new Cookie(REFRESH_TOKEN_NAME, refreshToken); refreshCookie.setHttpOnly(true); - refreshCookie.setSecure(false); // TODO: 운영환경에서는 true로 변경 (HTTPS) + refreshCookie.setSecure(false); // 운영환경에서는 true로 변경 (HTTPS) refreshCookie.setPath("/"); refreshCookie.setMaxAge(REFRESH_TOKEN_EXPIRE_TIME); response.addCookie(refreshCookie); diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index f4bf201d..77e6bfe8 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -98,17 +98,15 @@ private void setAuthentication(String token) { } /** - * 토큰 갱신을 처리합니다. - * 2. 액세스토큰이 만료되었으면 리프레시토큰을확인한다 - * 3. 리프레시토큰이 레디스의 저장값과 동일하면 토큰 재발급을 진행한다 - * 6. 리프레시토큰을 확인하는절차에서 리프레시토큰이 없을 경우 쿠키에 있는 모든 정보를 제거하고 로그인을 해달라고 메시지를 반환한다 + * 리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다. + * RTR(Refresh Token Rotation) 패턴을 적용하여 새로운 토큰 쌍을 생성합니다. */ private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) { try { - // 2. 리프레시 토큰 확인 + // 리프레시 토큰 확인 String refreshToken = cookieUtil.getRefreshTokenFromCookies(request); if (refreshToken == null) { - // 6. 리프레시 토큰이 없을 경우 쿠키 클리어 + // 리프레시 토큰이 없을 경우 쿠키 클리어 log.info("리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요"); clearAuthenticationAndCookies(response); return; diff --git a/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java b/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java index 20368042..52b9a768 100644 --- a/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java @@ -64,6 +64,7 @@ public GroupedOpenApi allApi() { .group("All APIs") .pathsToMatch("/api/**") .packagesToScan("com.ai.lawyer.domain") + .addOpenApiCustomizer(orderBySummaryNumber()) .build(); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 1b039746..1b4a1907 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -20,23 +20,22 @@ spring: enabled: always mail: - host: smtp.gmail.com - port: 587 - username: ${email_address} + host: smtp.daum.net + port: 465 + username: ${send_email_address} password: ${send_email_password} - from: ${send_email_address} properties: mail: smtp: auth: true - starttls: + ssl: enable: true - required: true connectiontimeout: 5000 timeout: 5000 writetimeout: 5000 auth-code-expiration-millis: 1800000 + ai: openai: api-key: ${OPENAI_API_KEY} diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java index 1a46835f..1592931b 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java @@ -604,4 +604,99 @@ void resetPassword_Fail_ValidationError() throws Exception { verify(memberService, never()).resetPassword(anyString(), anyString(), any()); } + + @Test + @DisplayName("비밀번호 검증 성공 - 로그인된 사용자") + void verifyPassword_Success_LoggedInUser() throws Exception { + // given + PasswordVerifyRequestDto requestDto = new PasswordVerifyRequestDto(); + requestDto.setPassword("password123"); + + given(memberService.extractLoginIdFromToken(anyString())).willReturn("test@example.com"); + given(memberService.verifyPassword("test@example.com", "password123")).willReturn(true); + + // when and then - with() 사용해 Authentication 주입 + mockMvc.perform(post("/api/auth/verifyPassword") + .with(csrf()) + .with(request -> { + request.setAttribute("org.springframework.security.web.authentication.WebAuthenticationDetails.REMOTE_ADDRESS", "127.0.0.1"); + return request; + }) + .principal(authentication) + .header("Authorization", "Bearer validAccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("비밀번호 검증 성공")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.success").value(true)); + + verify(memberService).extractLoginIdFromToken(anyString()); + verify(memberService).verifyPassword("test@example.com", "password123"); + } + + @Test + @DisplayName("비밀번호 검증 실패 - 비밀번호 불일치") + void verifyPassword_Fail_PasswordMismatch() throws Exception { + // given + PasswordVerifyRequestDto requestDto = new PasswordVerifyRequestDto(); + requestDto.setPassword("wrongPassword"); + + given(memberService.extractLoginIdFromToken(anyString())).willReturn("test@example.com"); + given(memberService.verifyPassword("test@example.com", "wrongPassword")).willReturn(false); + + // when and then + mockMvc.perform(post("/api/auth/verifyPassword") + .with(csrf()) + .principal(authentication) + .header("Authorization", "Bearer validAccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andDo(print()) + .andExpect(status().isBadRequest()); + + verify(memberService).extractLoginIdFromToken(anyString()); + verify(memberService).verifyPassword("test@example.com", "wrongPassword"); + } + + @Test + @DisplayName("비밀번호 검증 실패 - 로그인되지 않은 사용자") + void verifyPassword_Fail_NotLoggedIn() throws Exception { + // given + PasswordVerifyRequestDto requestDto = new PasswordVerifyRequestDto(); + requestDto.setPassword("password123"); + + // when and then - Authorization 헤더 없이 요청 + mockMvc.perform(post("/api/auth/verifyPassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andDo(print()) + .andExpect(status().isBadRequest()); + + verify(memberService, never()).extractLoginIdFromToken(anyString()); + verify(memberService, never()).verifyPassword(anyString(), anyString()); + } + + @Test + @DisplayName("이메일 인증번호 검증 실패 - 로그인된 사용자 접근 차단") + void verifyEmail_Fail_LoggedInUser() throws Exception { + // given + EmailVerifyCodeRequestDto requestDto = new EmailVerifyCodeRequestDto(); + requestDto.setLoginId("test@example.com"); + requestDto.setVerificationCode("123456"); + + // when and then - principal과 함께 요청 (로그인 상태) + mockMvc.perform(post("/api/auth/verifyEmail") + .with(csrf()) + .principal(authentication) + .header("Authorization", "Bearer validAccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andDo(print()) + .andExpect(status().isBadRequest()); + + verify(memberService, never()).verifyAuthCode(anyString(), anyString()); + } } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 1d622068..924322ce 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -640,4 +640,67 @@ void extractLoginIdFromToken_Fail_InvalidToken() { assertThat(result).isNull(); verify(tokenProvider).getLoginIdFromToken(token); } + + @Test + @DisplayName("비밀번호 검증 성공") + void verifyPassword_Success() { + // given + log.info("=== 비밀번호 검증 성공 테스트 시작 ==="); + String loginId = "test@example.com"; + String password = "password123"; + log.info("비밀번호 검증: 이메일={}", loginId); + + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + given(passwordEncoder.matches(password, member.getPassword())).willReturn(true); + log.info("Mock 설정 완료: 회원 존재, 비밀번호 일치"); + + // when + log.info("비밀번호 검증 서비스 호출 중..."); + boolean result = memberService.verifyPassword(loginId, password); + log.info("비밀번호 검증 완료: 결과={}", result); + + // then + log.info("검증 시작: 비밀번호 검증 결과 확인"); + assertThat(result).as("비밀번호 검증 성공").isTrue(); + verify(memberRepository).findByLoginId(loginId); + log.info("회원 존재 여부 조회 호출 확인"); + verify(passwordEncoder).matches(password, member.getPassword()); + log.info("비밀번호 일치 여부 검증 호출 확인"); + log.info("=== 비밀번호 검증 성공 테스트 완료 ==="); + } + + @Test + @DisplayName("비밀번호 검증 실패 - 비밀번호 불일치") + void verifyPassword_Fail_PasswordMismatch() { + // given + String loginId = "test@example.com"; + String password = "wrongPassword"; + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + given(passwordEncoder.matches(password, member.getPassword())).willReturn(false); + + // when + boolean result = memberService.verifyPassword(loginId, password); + + // then + assertThat(result).as("비밀번호 불일치로 검증 실패").isFalse(); + verify(memberRepository).findByLoginId(loginId); + verify(passwordEncoder).matches(password, member.getPassword()); + } + + @Test + @DisplayName("비밀번호 검증 실패 - 존재하지 않는 회원") + void verifyPassword_Fail_MemberNotFound() { + // given + String loginId = "nonexistent@example.com"; + String password = "password123"; + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.empty()); + + // when and then + assertThatThrownBy(() -> memberService.verifyPassword(loginId, password)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); + + verify(memberRepository).findByLoginId(loginId); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + } } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java index 3cb05b83..3d6510b4 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java @@ -19,6 +19,7 @@ import jakarta.servlet.http.Cookie; import static org.mockito.BDDMockito.*; import com.ai.lawyer.global.jwt.TokenProvider; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; @Import(SecurityConfig.class) @AutoConfigureMockMvc @@ -92,7 +93,7 @@ void t3() throws Exception { @Test @DisplayName("투표 통계 조회") void t4() throws Exception { - Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(java.util.Collections.emptyList()); + Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(new PollStaticsResponseDto()); mockMvc.perform(get("/api/polls/1/statics") .cookie(new Cookie("accessToken", "valid-access-token"))) diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java index 6ffae02e..2a3cdebb 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java @@ -4,6 +4,7 @@ import com.ai.lawyer.domain.poll.dto.PollCreateDto; import com.ai.lawyer.domain.poll.dto.PollVoteDto; import com.ai.lawyer.domain.poll.dto.PollUpdateDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -54,9 +55,9 @@ void t3() { @Test @DisplayName("투표 통계 조회") void t4() { - java.util.List expected = java.util.Collections.emptyList(); + PollStaticsResponseDto expected = new PollStaticsResponseDto(); Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(expected); - java.util.List result = pollService.getPollStatics(1L); + PollStaticsResponseDto result = pollService.getPollStatics(1L); assertThat(result).isEqualTo(expected); } diff --git a/backend/src/test/java/com/ai/lawyer/global/email/service/EmailServiceTest.java b/backend/src/test/java/com/ai/lawyer/global/email/service/EmailServiceTest.java new file mode 100644 index 00000000..fd1e3f19 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/email/service/EmailServiceTest.java @@ -0,0 +1,391 @@ +package com.ai.lawyer.global.email.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.UnsupportedEncodingException; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmailService 테스트") +class EmailServiceTest { + + @Mock + private JavaMailSender emailSender; + + @Mock + private EmailAuthService emailAuthService; + + @Mock + private MimeMessage mimeMessage; + + @InjectMocks + private EmailService emailService; + + private String toEmail; + private String loginId; + private String authCode; + + @BeforeEach + void setUp() { + toEmail = "test@example.com"; + loginId = "test@example.com"; + authCode = "123456"; + + // @Value로 주입되는 fromAddress 설정 + ReflectionTestUtils.setField(emailService, "fromAddress", "no-reply@trybalaw.com"); + } + + @Test + @DisplayName("인증번호 이메일 전송 성공") + void sendVerificationCode_Success() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendVerificationCode(toEmail, loginId); + + // then + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + } + + @Test + @DisplayName("인증번호 이메일 전송 실패 - 인증번호 생성 실패") + void sendVerificationCode_Fail_AuthCodeGenerationFailed() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)) + .willThrow(new RuntimeException("Redis 연결 실패")); + + // when and then + assertThatThrownBy(() -> emailService.sendVerificationCode(toEmail, loginId)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Redis 연결 실패"); + + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender, never()).createMimeMessage(); + verify(emailSender, never()).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("인증번호 이메일 전송 실패 - 이메일 전송 실패") + void sendVerificationCode_Fail_EmailSendingFailed() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doThrow(new MailSendException("메일 서버 연결 실패")) + .when(emailSender).send(mimeMessage); + + // when and then + assertThatThrownBy(() -> emailService.sendVerificationCode(toEmail, loginId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("이메일 전송 실패") + .hasCauseInstanceOf(MailSendException.class); + + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + } + + @Test + @DisplayName("인증번호 이메일 전송 실패 - MessagingException 발생") + void sendVerificationCode_Fail_MessagingException() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()).willThrow(new RuntimeException(new MessagingException("메시지 생성 실패"))); + + // when and then + assertThatThrownBy(() -> emailService.sendVerificationCode(toEmail, loginId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("이메일 전송 실패"); + + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender, never()).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("일반 이메일 전송 성공") + void sendEmail_Success() throws MessagingException, UnsupportedEncodingException { + // given + String title = "테스트 제목"; + String content = "

테스트 내용

"; + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendEmail(toEmail, title, content); + + // then + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + } + + @Test + @DisplayName("일반 이메일 전송 실패 - MessagingException") + void sendEmail_Fail_MessagingException() { + // given + String title = "테스트 제목"; + String content = "

테스트 내용

"; + given(emailSender.createMimeMessage()).willThrow(new RuntimeException(new MessagingException("메시지 생성 실패"))); + + // when and then + assertThatThrownBy(() -> emailService.sendEmail(toEmail, title, content)) + .isInstanceOf(RuntimeException.class); + + verify(emailSender).createMimeMessage(); + verify(emailSender, never()).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("일반 이메일 전송 실패 - MailSendException") + void sendEmail_Fail_MailSendException() { + // given + String title = "테스트 제목"; + String content = "

테스트 내용

"; + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doThrow(new MailSendException("메일 서버 연결 실패")) + .when(emailSender).send(mimeMessage); + + // when and then + assertThatThrownBy(() -> emailService.sendEmail(toEmail, title, content)) + .isInstanceOf(MailSendException.class) + .hasMessageContaining("메일 서버 연결 실패"); + + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + } + + @Test + @DisplayName("인증번호 이메일 내용 검증 - 올바른 형식으로 전송") + void sendVerificationCode_VerifyEmailContent() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendVerificationCode(toEmail, loginId); + + // then + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + + // 인증번호가 생성되고 이메일이 전송되었는지 확인 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(emailSender).send(messageCaptor.capture()); + } + + @Test + @DisplayName("여러 이메일 연속 전송 성공") + void sendMultipleEmails_Success() { + // given + String toEmail1 = "user1@example.com"; + String toEmail2 = "user2@example.com"; + String loginId1 = "user1@example.com"; + String loginId2 = "user2@example.com"; + + given(emailAuthService.generateAndSaveAuthCode(loginId1)).willReturn("111111"); + given(emailAuthService.generateAndSaveAuthCode(loginId2)).willReturn("222222"); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendVerificationCode(toEmail1, loginId1); + emailService.sendVerificationCode(toEmail2, loginId2); + + // then + verify(emailAuthService).generateAndSaveAuthCode(loginId1); + verify(emailAuthService).generateAndSaveAuthCode(loginId2); + verify(emailSender, times(2)).createMimeMessage(); + verify(emailSender, times(2)).send(mimeMessage); + } + + @Test + @DisplayName("인증번호 이메일 전송 시 올바른 제목 설정") + void sendVerificationCode_CorrectSubject() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendVerificationCode(toEmail, loginId); + + // then + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + + // 이메일 제목이 "이메일 인증번호 안내"로 설정되었는지는 + // sendEmail 메서드 호출을 통해 간접적으로 확인 + } + + @Test + @DisplayName("HTML 형식 이메일 전송 성공") + void sendEmail_HtmlContent_Success() throws MessagingException, UnsupportedEncodingException { + // given + String title = "HTML 이메일"; + String htmlContent = "

환영합니다

HTML 이메일입니다.

"; + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendEmail(toEmail, title, htmlContent); + + // then + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + } + + @Test + @DisplayName("빈 이메일 주소로 전송 시도 - MimeMessage 생성 시 실패") + void sendVerificationCode_EmptyEmail() { + // given + String emptyEmail = ""; + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()) + .willThrow(new RuntimeException(new MessagingException("잘못된 이메일 주소"))); + + // when and then + assertThatThrownBy(() -> emailService.sendVerificationCode(emptyEmail, loginId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("이메일 전송 실패"); + + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender, never()).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("null 이메일 주소로 전송 시도 - MimeMessage 생성 시 실패") + void sendVerificationCode_NullEmail() { + // given + String nullEmail = null; + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(authCode); + given(emailSender.createMimeMessage()) + .willThrow(new RuntimeException(new MessagingException("잘못된 이메일 주소"))); + + // when and then + assertThatThrownBy(() -> emailService.sendVerificationCode(nullEmail, loginId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("이메일 전송 실패"); + + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender, never()).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("발신자 주소가 올바르게 설정됨") + void sendEmail_FromAddressCorrectlySet() throws MessagingException, UnsupportedEncodingException { + // given + String title = "테스트"; + String content = "내용"; + String customFromAddress = "custom@trybalaw.com"; + ReflectionTestUtils.setField(emailService, "fromAddress", customFromAddress); + + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendEmail(toEmail, title, content); + + // then + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + + // fromAddress 필드가 올바르게 사용되었는지 확인 + assertThat(ReflectionTestUtils.getField(emailService, "fromAddress")) + .isEqualTo(customFromAddress); + } + + @Test + @DisplayName("인증번호 이메일 전송 실패 후 재시도") + void sendVerificationCode_RetryAfterFailure() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)) + .willReturn(authCode); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + + // 첫 번째 시도는 실패, 두 번째 시도는 성공 + doThrow(new MailSendException("일시적 오류")) + .doNothing() + .when(emailSender).send(mimeMessage); + + // when - 첫 번째 시도 실패 + assertThatThrownBy(() -> emailService.sendVerificationCode(toEmail, loginId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("이메일 전송 실패"); + + // 두 번째 시도 성공 + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn("654321"); + emailService.sendVerificationCode(toEmail, loginId); + + // then + verify(emailAuthService, times(2)).generateAndSaveAuthCode(loginId); + verify(emailSender, times(2)).createMimeMessage(); + verify(emailSender, times(2)).send(mimeMessage); + } + + @Test + @DisplayName("인증번호가 이메일 내용에 포함되어 있음") + void sendVerificationCode_AuthCodeInContent() { + // given + String customAuthCode = "999888"; + given(emailAuthService.generateAndSaveAuthCode(loginId)).willReturn(customAuthCode); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendVerificationCode(toEmail, loginId); + + // then + verify(emailAuthService).generateAndSaveAuthCode(loginId); + verify(emailSender).createMimeMessage(); + verify(emailSender).send(mimeMessage); + + // 인증번호가 생성되고 이메일에 사용되었는지 확인 + // (실제 이메일 내용 확인은 MimeMessageHelper를 통해 간접적으로 검증) + } + + @Test + @DisplayName("동일한 사용자에게 연속으로 인증번호 전송") + void sendVerificationCode_SameUserMultipleTimes() { + // given + given(emailAuthService.generateAndSaveAuthCode(loginId)) + .willReturn("111111") + .willReturn("222222") + .willReturn("333333"); + given(emailSender.createMimeMessage()).willReturn(mimeMessage); + doNothing().when(emailSender).send(mimeMessage); + + // when + emailService.sendVerificationCode(toEmail, loginId); + emailService.sendVerificationCode(toEmail, loginId); + emailService.sendVerificationCode(toEmail, loginId); + + // then + verify(emailAuthService, times(3)).generateAndSaveAuthCode(loginId); + verify(emailSender, times(3)).createMimeMessage(); + verify(emailSender, times(3)).send(mimeMessage); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java new file mode 100644 index 00000000..6826c53e --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java @@ -0,0 +1,617 @@ +package com.ai.lawyer.global.jwt; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.global.config.JwtProperties; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenProvider 테스트") +class TokenProviderTest { + + private static final Logger log = LoggerFactory.getLogger(TokenProviderTest.class); + + @Mock + private JwtProperties jwtProperties; + + @Mock + private JwtProperties.AccessToken accessTokenProperties; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private HashOperations hashOperations; + + @InjectMocks + private TokenProvider tokenProvider; + + private Member member; + private String secretKey; + + private static final String TOKEN_PREFIX = "tokens:"; + private static final String ACCESS_TOKEN_FIELD = "accessToken"; + private static final String ACCESS_TOKEN_EXPIRY_FIELD = "accessTokenExpiry"; + private static final String REFRESH_TOKEN_FIELD = "refreshToken"; + private static final String REFRESH_TOKEN_EXPIRY_FIELD = "refreshTokenExpiry"; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 + + @BeforeEach + void setUp() { + log.info("=== 테스트 초기화 시작 ==="); + + // Member 생성 + member = Member.builder() + .memberId(1L) + .loginId("test@example.com") + .password("encodedPassword") + .age(25) + .gender(Member.Gender.MALE) + .name("테스트") + .role(Member.Role.USER) + .build(); + + // JWT 설정 (최소 256비트 = 32바이트 이상의 키 필요) + secretKey = "testSecretKeyForJWTTokenGenerationAndValidation1234567890"; + + // Mock 설정 - lenient() 사용하여 모든 테스트에서 사용되지 않아도 경고하지 않음 + lenient().when(jwtProperties.getSecretKey()).thenReturn(secretKey); + lenient().when(jwtProperties.getAccessToken()).thenReturn(accessTokenProperties); + long accessTokenExpirationSeconds = 3600L; + lenient().when(accessTokenProperties.getExpirationSeconds()).thenReturn(accessTokenExpirationSeconds); + lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations); + + log.info("테스트 데이터 초기화 완료: memberId={}, loginId={}", member.getMemberId(), member.getLoginId()); + } + + @Test + @DisplayName("액세스 토큰 생성 성공 - JWT 구조, claims, 만료시간 검증") + void generateAccessToken_Success() { + // given + log.info("=== 액세스 토큰 생성 테스트 시작 ==="); + String tokenKey = TOKEN_PREFIX + member.getLoginId(); + + willDoNothing().given(hashOperations).put(eq(tokenKey), eq(ACCESS_TOKEN_FIELD), anyString()); + willDoNothing().given(hashOperations).put(eq(tokenKey), eq(ACCESS_TOKEN_EXPIRY_FIELD), anyString()); + given(redisTemplate.expire(eq(tokenKey), any(Duration.class))).willReturn(true); + log.info("Redis Mock 설정 완료"); + + // when + log.info("액세스 토큰 생성 호출 중..."); + String token = tokenProvider.generateAccessToken(member); + log.info("액세스 토큰 생성 완료: {}", token); + + // then + log.info("토큰 검증 시작"); + assertThat(token).as("토큰이 null이 아님").isNotNull(); + assertThat(token.split("\\.")).as("JWT는 3개 부분으로 구성됨 (header.payload.signature)").hasSize(3); + + // JWT 파싱하여 claims 검증 + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + assertThat(claims.get("loginid", String.class)).as("loginId claim 일치").isEqualTo("test@example.com"); + assertThat(claims.get("memberId", Long.class)).as("memberId claim 일치").isEqualTo(1L); + assertThat(claims.get("role", String.class)).as("role claim 일치").isEqualTo("USER"); + assertThat(claims.getIssuedAt()).as("발급 시간 존재").isNotNull(); + assertThat(claims.getExpiration()).as("만료 시간 존재").isNotNull(); + assertThat(claims.getExpiration()).as("만료 시간이 발급 시간 이후").isAfter(claims.getIssuedAt()); + log.info("JWT claims 검증 완료"); + + // Redis 저장 검증 + log.info("Redis 저장 검증 시작"); + verify(hashOperations).put(eq(tokenKey), eq(ACCESS_TOKEN_FIELD), eq(token)); + verify(hashOperations).put(eq(tokenKey), eq(ACCESS_TOKEN_EXPIRY_FIELD), anyString()); + verify(redisTemplate).expire(eq(tokenKey), eq(Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME))); + log.info("Redis 저장 호출 확인 완료"); + log.info("=== 액세스 토큰 생성 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰 생성 성공 - UUID 형식 및 Redis 저장 검증") + void generateRefreshToken_Success() { + // given + log.info("=== 리프레시 토큰 생성 테스트 시작 ==="); + String tokenKey = TOKEN_PREFIX + member.getLoginId(); + + willDoNothing().given(hashOperations).put(eq(tokenKey), eq(REFRESH_TOKEN_FIELD), anyString()); + willDoNothing().given(hashOperations).put(eq(tokenKey), eq(REFRESH_TOKEN_EXPIRY_FIELD), anyString()); + given(hashOperations.get(eq(tokenKey), eq(REFRESH_TOKEN_FIELD))).willAnswer(invocation -> { + // 저장 확인용 mock - 생성된 토큰 반환 + return null; // 실제로는 저장된 토큰이 반환되지만, 생성 로직에서는 null 체크 없이 진행 + }); + given(hashOperations.get(eq(tokenKey), eq(REFRESH_TOKEN_EXPIRY_FIELD))).willReturn(String.valueOf(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME * 1000)); + given(redisTemplate.expire(eq(tokenKey), any(Duration.class))).willReturn(true); + log.info("Redis Mock 설정 완료"); + + // when + log.info("리프레시 토큰 생성 호출 중..."); + String refreshToken = tokenProvider.generateRefreshToken(member); + log.info("리프레시 토큰 생성 완료: {}", refreshToken); + + // then + log.info("토큰 검증 시작"); + assertThat(refreshToken).as("토큰이 null이 아님").isNotNull(); + assertThat(refreshToken).as("토큰이 비어있지 않음").isNotEmpty(); + + // UUID 형식 검증 (8-4-4-4-12) + String uuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + assertThat(refreshToken).as("UUID 형식 일치").matches(uuidPattern); + log.info("UUID 형식 검증 완료"); + + // Redis 저장 검증 + log.info("Redis 저장 검증 시작"); + verify(hashOperations).put(eq(tokenKey), eq(REFRESH_TOKEN_FIELD), eq(refreshToken)); + verify(hashOperations).put(eq(tokenKey), eq(REFRESH_TOKEN_EXPIRY_FIELD), anyString()); + verify(redisTemplate).expire(eq(tokenKey), eq(Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME))); + verify(hashOperations).get(eq(tokenKey), eq(REFRESH_TOKEN_FIELD)); + verify(hashOperations).get(eq(tokenKey), eq(REFRESH_TOKEN_EXPIRY_FIELD)); + log.info("Redis 저장 및 저장 확인 호출 검증 완료"); + log.info("=== 리프레시 토큰 생성 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰 검증 성공 - 유효한 토큰") + void validateTokenWithResult_Valid() { + // given + log.info("=== 토큰 검증(유효) 테스트 시작 ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String token = tokenProvider.generateAccessToken(member); + log.info("유효한 토큰 생성 완료"); + + // when + log.info("토큰 검증 호출 중..."); + TokenProvider.TokenValidationResult result = tokenProvider.validateTokenWithResult(token); + log.info("토큰 검증 완료: {}", result); + + // then + assertThat(result).as("토큰이 VALID 상태").isEqualTo(TokenProvider.TokenValidationResult.VALID); + log.info("=== 토큰 검증(유효) 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰 검증 실패 - 만료된 토큰") + void validateTokenWithResult_Expired() { + // given + log.info("=== 토큰 검증(만료) 테스트 시작 ==="); + + // 만료된 토큰 생성 (expirationSeconds를 -1로 설정) + lenient().when(accessTokenProperties.getExpirationSeconds()).thenReturn(-1L); + + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String expiredToken = tokenProvider.generateAccessToken(member); + log.info("만료된 토큰 생성 완료"); + + // when + log.info("토큰 검증 호출 중..."); + TokenProvider.TokenValidationResult result = tokenProvider.validateTokenWithResult(expiredToken); + log.info("토큰 검증 완료: {}", result); + + // then + assertThat(result).as("토큰이 EXPIRED 상태").isEqualTo(TokenProvider.TokenValidationResult.EXPIRED); + log.info("=== 토큰 검증(만료) 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰 검증 실패 - 잘못된 토큰 형식") + void validateTokenWithResult_Invalid_MalformedToken() { + // given + log.info("=== 토큰 검증(잘못된 형식) 테스트 시작 ==="); + String invalidToken = "invalid.token.format"; + log.info("잘못된 형식의 토큰: {}", invalidToken); + + // when + log.info("토큰 검증 호출 중..."); + TokenProvider.TokenValidationResult result = tokenProvider.validateTokenWithResult(invalidToken); + log.info("토큰 검증 완료: {}", result); + + // then + assertThat(result).as("토큰이 INVALID 상태").isEqualTo(TokenProvider.TokenValidationResult.INVALID); + log.info("=== 토큰 검증(잘못된 형식) 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰 검증 실패 - null 토큰") + void validateTokenWithResult_Invalid_NullToken() { + // given + log.info("=== 토큰 검증(null) 테스트 시작 ==="); + String nullToken = null; + + // when + log.info("토큰 검증 호출 중..."); + TokenProvider.TokenValidationResult result = tokenProvider.validateTokenWithResult(nullToken); + log.info("토큰 검증 완료: {}", result); + + // then + assertThat(result).as("null 토큰은 INVALID 상태").isEqualTo(TokenProvider.TokenValidationResult.INVALID); + log.info("=== 토큰 검증(null) 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰에서 loginId 추출 성공") + void getLoginIdFromToken_Success() { + // given + log.info("=== 토큰에서 loginId 추출 테스트 시작 ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String token = tokenProvider.generateAccessToken(member); + log.info("토큰 생성 완료"); + + // when + log.info("loginId 추출 호출 중..."); + String loginId = tokenProvider.getLoginIdFromToken(token); + log.info("loginId 추출 완료: {}", loginId); + + // then + assertThat(loginId).as("loginId가 null이 아님").isNotNull(); + assertThat(loginId).as("loginId 일치").isEqualTo("test@example.com"); + log.info("=== 토큰에서 loginId 추출 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰에서 loginId 추출 실패 - 유효하지 않은 토큰") + void getLoginIdFromToken_Fail_InvalidToken() { + // given + log.info("=== 토큰에서 loginId 추출 실패 테스트 시작 ==="); + String invalidToken = "invalid.token.format"; + + // when + log.info("loginId 추출 호출 중..."); + String loginId = tokenProvider.getLoginIdFromToken(invalidToken); + log.info("loginId 추출 결과: {}", loginId); + + // then + assertThat(loginId).as("유효하지 않은 토큰에서는 null 반환").isNull(); + log.info("=== 토큰에서 loginId 추출 실패 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰에서 memberId 추출 성공") + void getMemberIdFromToken_Success() { + // given + log.info("=== 토큰에서 memberId 추출 테스트 시작 ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String token = tokenProvider.generateAccessToken(member); + log.info("토큰 생성 완료"); + + // when + log.info("memberId 추출 호출 중..."); + Long memberId = tokenProvider.getMemberIdFromToken(token); + log.info("memberId 추출 완료: {}", memberId); + + // then + assertThat(memberId).as("memberId가 null이 아님").isNotNull(); + assertThat(memberId).as("memberId 일치").isEqualTo(1L); + log.info("=== 토큰에서 memberId 추출 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰에서 memberId 추출 실패 - 유효하지 않은 토큰") + void getMemberIdFromToken_Fail_InvalidToken() { + // given + log.info("=== 토큰에서 memberId 추출 실패 테스트 시작 ==="); + String invalidToken = "invalid.token.format"; + + // when + log.info("memberId 추출 호출 중..."); + Long memberId = tokenProvider.getMemberIdFromToken(invalidToken); + log.info("memberId 추출 결과: {}", memberId); + + // then + assertThat(memberId).as("유효하지 않은 토큰에서는 null 반환").isNull(); + log.info("=== 토큰에서 memberId 추출 실패 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰에서 role 추출 성공") + void getRoleFromToken_Success() { + // given + log.info("=== 토큰에서 role 추출 테스트 시작 ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String token = tokenProvider.generateAccessToken(member); + log.info("토큰 생성 완료"); + + // when + log.info("role 추출 호출 중..."); + String role = tokenProvider.getRoleFromToken(token); + log.info("role 추출 완료: {}", role); + + // then + assertThat(role).as("role이 null이 아님").isNotNull(); + assertThat(role).as("role 일치").isEqualTo("USER"); + log.info("=== 토큰에서 role 추출 테스트 완료 ==="); + } + + @Test + @DisplayName("만료된 토큰에서 loginId 추출 성공") + void getLoginIdFromExpiredToken_Success() { + // given + log.info("=== 만료된 토큰에서 loginId 추출 테스트 시작 ==="); + + // 만료된 토큰 생성 + lenient().when(accessTokenProperties.getExpirationSeconds()).thenReturn(-1L); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String expiredToken = tokenProvider.generateAccessToken(member); + log.info("만료된 토큰 생성 완료"); + + // when + log.info("만료된 토큰에서 loginId 추출 호출 중..."); + String loginId = tokenProvider.getLoginIdFromExpiredToken(expiredToken); + log.info("loginId 추출 완료: {}", loginId); + + // then + assertThat(loginId).as("만료된 토큰에서도 loginId 추출 가능").isNotNull(); + assertThat(loginId).as("loginId 일치").isEqualTo("test@example.com"); + log.info("=== 만료된 토큰에서 loginId 추출 테스트 완료 ==="); + } + + @Test + @DisplayName("만료된 토큰에서 loginId 추출 실패 - 잘못된 토큰") + void getLoginIdFromExpiredToken_Fail_InvalidToken() { + // given + log.info("=== 만료된 토큰에서 loginId 추출 실패 테스트 시작 ==="); + String invalidToken = "invalid.token.format"; + + // when + log.info("loginId 추출 호출 중..."); + String loginId = tokenProvider.getLoginIdFromExpiredToken(invalidToken); + log.info("loginId 추출 결과: {}", loginId); + + // then + assertThat(loginId).as("잘못된 토큰에서는 null 반환").isNull(); + log.info("=== 만료된 토큰에서 loginId 추출 실패 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰 검증 성공 - Redis에서 일치하는 토큰 확인") + void validateRefreshToken_Success() { + // given + log.info("=== 리프레시 토큰 검증 성공 테스트 시작 ==="); + String loginId = "test@example.com"; + String refreshToken = "test-refresh-token"; + String tokenKey = TOKEN_PREFIX + loginId; + + given(hashOperations.get(tokenKey, REFRESH_TOKEN_FIELD)).willReturn(refreshToken); + log.info("Redis Mock 설정 완료: 저장된 토큰={}", refreshToken); + + // when + log.info("리프레시 토큰 검증 호출 중..."); + boolean isValid = tokenProvider.validateRefreshToken(loginId, refreshToken); + log.info("검증 결과: {}", isValid); + + // then + assertThat(isValid).as("리프레시 토큰 검증 성공").isTrue(); + verify(hashOperations).get(tokenKey, REFRESH_TOKEN_FIELD); + log.info("=== 리프레시 토큰 검증 성공 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰 검증 실패 - Redis에 저장된 토큰과 불일치") + void validateRefreshToken_Fail_TokenMismatch() { + // given + log.info("=== 리프레시 토큰 검증 실패(불일치) 테스트 시작 ==="); + String loginId = "test@example.com"; + String refreshToken = "test-refresh-token"; + String storedToken = "different-refresh-token"; + String tokenKey = TOKEN_PREFIX + loginId; + + given(hashOperations.get(tokenKey, REFRESH_TOKEN_FIELD)).willReturn(storedToken); + log.info("Redis Mock 설정 완료: 저장된 토큰={}, 입력 토큰={}", storedToken, refreshToken); + + // when + log.info("리프레시 토큰 검증 호출 중..."); + boolean isValid = tokenProvider.validateRefreshToken(loginId, refreshToken); + log.info("검증 결과: {}", isValid); + + // then + assertThat(isValid).as("리프레시 토큰 불일치로 검증 실패").isFalse(); + verify(hashOperations).get(tokenKey, REFRESH_TOKEN_FIELD); + log.info("=== 리프레시 토큰 검증 실패(불일치) 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰 검증 실패 - Redis에 토큰이 없음") + void validateRefreshToken_Fail_NoTokenInRedis() { + // given + log.info("=== 리프레시 토큰 검증 실패(없음) 테스트 시작 ==="); + String loginId = "test@example.com"; + String refreshToken = "test-refresh-token"; + String tokenKey = TOKEN_PREFIX + loginId; + + given(hashOperations.get(tokenKey, REFRESH_TOKEN_FIELD)).willReturn(null); + log.info("Redis Mock 설정 완료: 저장된 토큰 없음"); + + // when + log.info("리프레시 토큰 검증 호출 중..."); + boolean isValid = tokenProvider.validateRefreshToken(loginId, refreshToken); + log.info("검증 결과: {}", isValid); + + // then + assertThat(isValid).as("Redis에 토큰이 없어 검증 실패").isFalse(); + verify(hashOperations).get(tokenKey, REFRESH_TOKEN_FIELD); + log.info("=== 리프레시 토큰 검증 실패(없음) 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰으로 사용자명 찾기 성공") + void findUsernameByRefreshToken_Success() { + // given + log.info("=== 리프레시 토큰으로 사용자명 찾기 성공 테스트 시작 ==="); + String refreshToken = "test-refresh-token"; + String loginId = "test@example.com"; + String tokenKey = TOKEN_PREFIX + loginId; + + Set keys = new HashSet<>(); + keys.add(tokenKey); + + given(redisTemplate.keys(TOKEN_PREFIX + "*")).willReturn(keys); + given(hashOperations.get(tokenKey, REFRESH_TOKEN_FIELD)).willReturn(refreshToken); + log.info("Redis Mock 설정 완료: 저장된 토큰 키={}", tokenKey); + + // when + log.info("사용자명 찾기 호출 중..."); + String foundLoginId = tokenProvider.findUsernameByRefreshToken(refreshToken); + log.info("찾은 사용자명: {}", foundLoginId); + + // then + assertThat(foundLoginId).as("사용자명을 찾음").isNotNull(); + assertThat(foundLoginId).as("사용자명 일치").isEqualTo(loginId); + verify(redisTemplate).keys(TOKEN_PREFIX + "*"); + verify(hashOperations).get(tokenKey, REFRESH_TOKEN_FIELD); + log.info("=== 리프레시 토큰으로 사용자명 찾기 성공 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰으로 사용자명 찾기 실패 - 일치하는 토큰 없음") + void findUsernameByRefreshToken_Fail_NoMatch() { + // given + log.info("=== 리프레시 토큰으로 사용자명 찾기 실패 테스트 시작 ==="); + String refreshToken = "non-existent-token"; + String loginId = "test@example.com"; + String tokenKey = TOKEN_PREFIX + loginId; + + Set keys = new HashSet<>(); + keys.add(tokenKey); + + given(redisTemplate.keys(TOKEN_PREFIX + "*")).willReturn(keys); + given(hashOperations.get(tokenKey, REFRESH_TOKEN_FIELD)).willReturn("different-token"); + log.info("Redis Mock 설정 완료: 일치하는 토큰 없음"); + + // when + log.info("사용자명 찾기 호출 중..."); + String foundLoginId = tokenProvider.findUsernameByRefreshToken(refreshToken); + log.info("찾은 사용자명: {}", foundLoginId); + + // then + assertThat(foundLoginId).as("일치하는 토큰이 없어 null 반환").isNull(); + verify(redisTemplate).keys(TOKEN_PREFIX + "*"); + verify(hashOperations).get(tokenKey, REFRESH_TOKEN_FIELD); + log.info("=== 리프레시 토큰으로 사용자명 찾기 실패 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰으로 사용자명 찾기 실패 - Redis에 키가 없음") + void findUsernameByRefreshToken_Fail_NoKeysInRedis() { + // given + log.info("=== 리프레시 토큰으로 사용자명 찾기 실패(키 없음) 테스트 시작 ==="); + String refreshToken = "test-refresh-token"; + + Set keys = new HashSet<>(); + + given(redisTemplate.keys(TOKEN_PREFIX + "*")).willReturn(keys); + log.info("Redis Mock 설정 완료: 저장된 키 없음"); + + // when + log.info("사용자명 찾기 호출 중..."); + String foundLoginId = tokenProvider.findUsernameByRefreshToken(refreshToken); + log.info("찾은 사용자명: {}", foundLoginId); + + // then + assertThat(foundLoginId).as("Redis에 키가 없어 null 반환").isNull(); + verify(redisTemplate).keys(TOKEN_PREFIX + "*"); + verify(hashOperations, never()).get(anyString(), anyString()); + log.info("=== 리프레시 토큰으로 사용자명 찾기 실패(키 없음) 테스트 완료 ==="); + } + + @Test + @DisplayName("모든 토큰 삭제 성공 - Redis에서 사용자의 모든 토큰 삭제") + void deleteAllTokens_Success() { + // given + log.info("=== 모든 토큰 삭제 테스트 시작 ==="); + String loginId = "test@example.com"; + String tokenKey = TOKEN_PREFIX + loginId; + + given(redisTemplate.delete(tokenKey)).willReturn(true); + log.info("Redis Mock 설정 완료"); + + // when + log.info("토큰 삭제 호출 중..."); + tokenProvider.deleteAllTokens(loginId); + log.info("토큰 삭제 완료"); + + // then + log.info("Redis 삭제 호출 검증"); + verify(redisTemplate).delete(tokenKey); + log.info("=== 모든 토큰 삭제 테스트 완료 ==="); + } + + @Test + @DisplayName("여러 사용자의 토큰 생성 및 검증 - 멀티 유저 시나리오") + void multipleUsers_TokenGeneration() { + // given + log.info("=== 멀티 유저 토큰 생성 테스트 시작 ==="); + Member user1 = Member.builder() + .memberId(1L) + .loginId("user1@example.com") + .role(Member.Role.USER) + .build(); + + Member user2 = Member.builder() + .memberId(2L) + .loginId("user2@example.com") + .role(Member.Role.ADMIN) + .build(); + + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + // when + log.info("두 사용자의 토큰 생성 중..."); + String token1 = tokenProvider.generateAccessToken(user1); + String token2 = tokenProvider.generateAccessToken(user2); + log.info("토큰 생성 완료"); + + // then + log.info("토큰 검증 시작"); + assertThat(token1).as("user1 토큰 생성됨").isNotNull(); + assertThat(token2).as("user2 토큰 생성됨").isNotNull(); + assertThat(token1).as("두 토큰은 다름").isNotEqualTo(token2); + + String loginId1 = tokenProvider.getLoginIdFromToken(token1); + String loginId2 = tokenProvider.getLoginIdFromToken(token2); + String role1 = tokenProvider.getRoleFromToken(token1); + String role2 = tokenProvider.getRoleFromToken(token2); + + assertThat(loginId1).as("user1 loginId 일치").isEqualTo("user1@example.com"); + assertThat(loginId2).as("user2 loginId 일치").isEqualTo("user2@example.com"); + assertThat(role1).as("user1 role 일치").isEqualTo("USER"); + assertThat(role2).as("user2 role 일치").isEqualTo("ADMIN"); + log.info("=== 멀티 유저 토큰 생성 테스트 완료 ==="); + } +} \ No newline at end of file diff --git a/backend/src/test/resources/application-test-ci.yml b/backend/src/test/resources/application-test-ci.yml index 3bb8f0ec..8bcf3783 100644 --- a/backend/src/test/resources/application-test-ci.yml +++ b/backend/src/test/resources/application-test-ci.yml @@ -22,18 +22,16 @@ spring: highlight_sql: true mail: - host: smtp.gmail.com - port: 587 - username: ${email_address} + host: smtp.daum.net + port: 465 + username: ${send_email_address} password: ${send_email_password} - from: ${send_email_address} properties: mail: smtp: auth: true - starttls: + ssl: enable: true - required: true connectiontimeout: 5000 timeout: 5000 writetimeout: 5000 diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 3549a66b..951e1037 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -22,18 +22,16 @@ spring: highlight_sql: true mail: - host: smtp.gmail.com - port: 587 - username: ${email_address} + host: smtp.daum.net + port: 465 + username: ${send_email_address} password: ${send_email_password} - from: ${send_email_address} properties: mail: smtp: auth: true - starttls: + ssl: enable: true - required: true connectiontimeout: 5000 timeout: 5000 writetimeout: 5000