diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index a5a2acf..3551cbc 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -116,13 +116,20 @@ public CorsConfigurationSource corsConfigurationSource() { "https://valanserver.store:8081", "https://valanserver.store:8082" )); + configuration.setAllowedOriginPatterns(Arrays.asList( + "http://localhost:*", + "http://127.0.0.1:*" + )); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/member/titles", configuration); + source.registerCorsConfiguration("/member/titles/**", configuration); source.registerCorsConfiguration("/**", configuration); return source; } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index 19d3a28..a22a73d 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -3,14 +3,28 @@ import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleAdminResponse; +import com.valanse.valanse.dto.Title.TitleDeleteResponse; +import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.dto.Title.TitleListResponse; +import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateResponse; import com.valanse.valanse.service.MemberProfileService.MemberProfileService; +import com.valanse.valanse.service.PointService.PointService; +import com.valanse.valanse.service.TitleService.TitleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; +import java.util.List; import java.util.Map; @Tag(name = "회원 정보 API", description = "프로필 조회 등 회원 정보 관련 기능") @@ -20,6 +34,8 @@ public class MemberController { private final MemberProfileService memberProfileService; + private final PointService pointService; + private final TitleService titleService; @Operation( summary = "회원 프로필 정보 저장", @@ -92,4 +108,95 @@ public ResponseEntity getMyProfile() { MemberMyPageResponse response = memberProfileService.getMyProfile(); return ResponseEntity.ok(response); } -} \ No newline at end of file + + @Operation( + summary = "포인트 지급 내역 조회", + description = "현재 로그인한 회원의 포인트 지급 내역을 조회합니다. 최신순으로 정렬되어 반환됩니다." + ) + @GetMapping("/point-history") + public ResponseEntity getPointHistory() { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + PointHistoryResponse response = pointService.getPointHistory(userId); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "칭호 선택 목록 조회", + description = "현재 로그인한 회원 기준으로 기본, 보유, 미보유 칭호를 분리해서 조회합니다." + ) + @GetMapping("/titles") + public ResponseEntity getTitles() { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleListResponse response = titleService.getTitleList(userId); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "관리자 칭호 목록 조회", + description = "관리자 권한으로 잠김/보유 여부와 상관없이 칭호 마스터 데이터 목록을 조회합니다." + ) + @GetMapping("/titles/admin") + public ResponseEntity> getTitlesForAdmin() { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + List response = titleService.getTitleListForAdmin(userId); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "관리자 칭호 생성", + description = "관리자 권한으로 새로운 칭호 마스터 데이터를 생성합니다." + ) + @PostMapping("/titles") + public ResponseEntity createTitle(@RequestBody TitleCreateRequest request) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleCreateResponse response = titleService.createTitle(userId, request); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "관리자 칭호 수정", + description = "관리자 권한으로 칭호 마스터 데이터를 수정합니다." + ) + @PatchMapping("/titles/{titleId}") + public ResponseEntity updateTitle( + @PathVariable Long titleId, + @RequestBody TitleUpdateRequest request + ) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleUpdateResponse response = titleService.updateTitle(userId, titleId, request); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "관리자 칭호 삭제", + description = "관리자 권한으로 칭호를 비활성화합니다. 삭제 대상 칭호를 장착 중인 회원은 활성 기본 칭호로 변경됩니다." + ) + @DeleteMapping("/titles/{titleId}") + public ResponseEntity deleteTitle(@PathVariable Long titleId) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleDeleteResponse response = titleService.deleteTitle(userId, titleId); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "칭호 장착", + description = "현재 로그인한 회원이 보유한 칭호를 대표 칭호로 선택합니다." + ) + @PostMapping("/titles/{titleId}/equip") + public ResponseEntity equipTitle(@PathVariable Long titleId) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleEquipResponse response = titleService.equipTitle(userId, titleId); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "칭호 구매", + description = "현재 로그인한 회원이 포인트로 구매 가능한 칭호를 구매합니다." + ) + @PostMapping("/titles/{titleId}/purchase") + public ResponseEntity purchaseTitle(@PathVariable Long titleId) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitlePurchaseResponse response = titleService.purchaseTitle(userId, titleId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/valanse/valanse/domain/MemberProfile.java b/src/main/java/com/valanse/valanse/domain/MemberProfile.java index 068a922..7cafdd8 100644 --- a/src/main/java/com/valanse/valanse/domain/MemberProfile.java +++ b/src/main/java/com/valanse/valanse/domain/MemberProfile.java @@ -11,6 +11,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.ArrayList; +import java.util.List; + @Builder @AllArgsConstructor @NoArgsConstructor @@ -43,6 +46,13 @@ public class MemberProfile extends BaseEntity { private String mbti; + @Builder.Default + private long point = 0L; + + @Builder.Default + @OneToMany(mappedBy = "memberProfile", cascade = CascadeType.ALL) + private List memberProfileTitles = new ArrayList<>(); + public void update(String nickname, Gender gender, Age age, MbtiIe mbtiIe, MbtiTf mbtiTf, String mbti) { this.nickname = nickname; this.gender = gender; @@ -52,4 +62,21 @@ public void update(String nickname, Gender gender, Age age, MbtiIe mbtiIe, MbtiT this.mbti = mbti; } + public void addPoint(long amount) { + this.point += amount; + } + + public boolean hasEnoughPoint(long amount) { + return this.point >= amount; + } + + public void subtractPoint(long amount) { + if (amount < 0) { + throw new IllegalArgumentException("차감할 포인트는 0 이상이어야 합니다."); + } + if (!hasEnoughPoint(amount)) { + throw new IllegalArgumentException("포인트가 부족합니다."); + } + this.point -= amount; + } } diff --git a/src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java b/src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java new file mode 100644 index 0000000..5cd0039 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java @@ -0,0 +1,59 @@ +package com.valanse.valanse.domain; + +import com.valanse.valanse.domain.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Entity +public class MemberProfileTitle extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_profile_id", nullable = false) + private MemberProfile memberProfile; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "title_id", nullable = false) + private Title title; + + @Builder.Default + @Column(nullable = false) + private boolean equipped = false; + + private LocalDateTime acquiredAt; + + @PrePersist + public void prePersist() { + if (acquiredAt == null) { + acquiredAt = LocalDateTime.now(); + } + } + + public void equip() { + this.equipped = true; + } + + public void unequip() { + this.equipped = false; + } +} diff --git a/src/main/java/com/valanse/valanse/domain/PointHistory.java b/src/main/java/com/valanse/valanse/domain/PointHistory.java new file mode 100644 index 0000000..6d4c9f1 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/PointHistory.java @@ -0,0 +1,44 @@ +package com.valanse.valanse.domain; + +import com.valanse.valanse.domain.enums.PointType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PointHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + private Long amount; + + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.VARCHAR) + @Column(nullable = false, length = 30) + private PointType type; + + private Long remainingPoint; + + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/valanse/valanse/domain/Title.java b/src/main/java/com/valanse/valanse/domain/Title.java new file mode 100644 index 0000000..80c7b20 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/Title.java @@ -0,0 +1,93 @@ +package com.valanse.valanse.domain; + +import com.valanse.valanse.domain.common.BaseEntity; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Entity +public class Title extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String code; + + @Column(nullable = false) + private String name; + + private String description; + + @Builder.Default + private long price = 0L; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TitleTier tier; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TitleAcquisitionType acquisitionType; + + private String requirementText; + + @Builder.Default + private boolean active = true; + + @Builder.Default + private int displayOrder = 0; + + public boolean isDefaultTitle() { + return acquisitionType == TitleAcquisitionType.DEFAULT; + } + + public boolean isPointPurchaseTitle() { + return acquisitionType == TitleAcquisitionType.POINT_PURCHASE; + } + + public boolean isPurchasable(long point) { + return active && isPointPurchaseTitle() && point >= price; + } + + public void deactivate() { + this.active = false; + } + + public void update( + String code, + String name, + String description, + long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + boolean active, + int displayOrder + ) { + this.code = code; + this.name = name; + this.description = description; + this.price = price; + this.tier = tier; + this.acquisitionType = acquisitionType; + this.requirementText = requirementText; + this.active = active; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/valanse/valanse/domain/enums/PointType.java b/src/main/java/com/valanse/valanse/domain/enums/PointType.java new file mode 100644 index 0000000..9acca90 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/enums/PointType.java @@ -0,0 +1,10 @@ +package com.valanse.valanse.domain.enums; + +public enum PointType { + COMMENT_CREATE, + POST_CREATE, + POST_VOTED, + HOT_ISSUE, + SIGN_UP, + TITLE_PURCHASE +} diff --git a/src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java b/src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java new file mode 100644 index 0000000..c38578f --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.domain.enums; + +public enum TitleAcquisitionType { + DEFAULT, + POINT_PURCHASE, + ACHIEVEMENT, + SEASON, + EVENT +} diff --git a/src/main/java/com/valanse/valanse/domain/enums/TitleTier.java b/src/main/java/com/valanse/valanse/domain/enums/TitleTier.java new file mode 100644 index 0000000..5843c6a --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/enums/TitleTier.java @@ -0,0 +1,10 @@ +package com.valanse.valanse.domain.enums; + +public enum TitleTier { + BASIC, + TIER_1, + TIER_2, + TIER_3, + SEASON, + RARE +} diff --git a/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java index 1aae679..02cd181 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java @@ -12,6 +12,7 @@ public class CommentReplyResponseDto { private Long id; private String nickname; + private String title; private LocalDateTime createdAt; private String content; private int likeCount; @@ -22,4 +23,4 @@ public class CommentReplyResponseDto { private Long daysAgo; private Long hoursAgo; private boolean canDelete; -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java index 5170b41..ed97b7a 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java @@ -13,6 +13,7 @@ public class CommentResponseDto { private Long commentId; private Long voteId; private String nickname; + private String title; private LocalDateTime commentCreatedAt; // 추가 private LocalDateTime voteCreatedAt; // 추가 private String content; @@ -25,7 +26,7 @@ public class CommentResponseDto { private Boolean canDelete; @QueryProjection - public CommentResponseDto(Long commentId, Long voteId, String nickname, + public CommentResponseDto(Long commentId, Long voteId, String nickname, String title, LocalDateTime commentCreatedAt, LocalDateTime voteCreatedAt, // 추가 String content, Integer likeCount, Integer replyCount, LocalDateTime deletedAt, String voteOptionLabel, Long daysAgo, Long hoursAgo, @@ -33,6 +34,7 @@ public CommentResponseDto(Long commentId, Long voteId, String nickname, this.commentId = commentId; this.voteId = voteId; this.nickname = nickname; + this.title = title; this.commentCreatedAt = commentCreatedAt; this.voteCreatedAt = voteCreatedAt; this.content = content; diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java index d574764..b81f768 100644 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java @@ -10,8 +10,8 @@ public record MyPageInfo( String nickname, String gender, String age, - String mbti + String mbti, + String title, + long point ){} } - - diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java index a524ff6..c64b2bc 100644 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java @@ -12,6 +12,8 @@ public record Info( String mbtiIe, String mbtiTf, String mbti, - Role role + Role role, + String title, + long point ) {} } diff --git a/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java new file mode 100644 index 0000000..9e75518 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.PointHistory; + +import com.valanse.valanse.domain.enums.PointType; + +import java.util.List; + +public record PointHistoryResponse( + List pointHistory +) { + public record PointHistoryItem( + Long id, + Long amount, + Long remainingPoint, + PointType type, + String typeDescription, + String createdAt + ) {} +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java new file mode 100644 index 0000000..940a14d --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleAdminResponse( + Long titleId, + String code, + String titleName, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java new file mode 100644 index 0000000..6dba156 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleCreateRequest( + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + Boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java new file mode 100644 index 0000000..68abc5c --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleCreateResponse( + Long titleId, + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java new file mode 100644 index 0000000..4e72cad --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java @@ -0,0 +1,11 @@ +package com.valanse.valanse.dto.Title; + +public record TitleDeleteResponse( + Long deletedTitleId, + String deletedTitle, + Long fallbackTitleId, + String fallbackTitle, + int reassignedCount, + boolean active +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java new file mode 100644 index 0000000..0cebb9e --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java @@ -0,0 +1,8 @@ +package com.valanse.valanse.dto.Title; + +public record TitleEquipResponse( + Long titleId, + String title, + boolean equipped +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java new file mode 100644 index 0000000..46a1c79 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java @@ -0,0 +1,27 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +import java.util.List; + +public record TitleListResponse( + List defaultTitles, + List ownedTitles, + List lockedTitles +) { + public record TitleSummaryResponse( + Long titleId, + String title, + String description, + TitleTier tier, + TitleAcquisitionType acquisitionType, + boolean owned, + boolean equipped, + boolean locked, + Long price, + String requirementText, + String lockReason + ) { + } +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java new file mode 100644 index 0000000..ddf3d0e --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.dto.Title; + +public record TitlePurchaseResponse( + Long titleId, + String title, + boolean owned, + long remainingPoint +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java new file mode 100644 index 0000000..0c7d235 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleUpdateRequest( + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + Boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java new file mode 100644 index 0000000..055cc6f --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleUpdateResponse( + Long titleId, + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java index c2fc4d1..846ad70 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java @@ -21,7 +21,8 @@ public class HotIssueVoteResponse { private String category; // 가장 투표 참여 횟수가 많은 투표의 카테고리 private Integer totalParticipants; // 가장 투표 참여 횟수가 많은 투표의 총 투표 수 private String createdBy; // 가장 투표 참여 횟수가 많은 투표를 생성한 사람의 닉네임 + private String creatorTitle; private LocalDateTime createdAt; // 투표 생성 날짜 private PinType pinType; // 고정 여부 private List options; // 투표 옵션 리스트 (content, vote_count) -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java index 766b9bc..d0e25da 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java @@ -16,6 +16,7 @@ public class VoteDetailResponse { private VoteCategory category; private Integer totalVoteCount; private String creatorNickname; // 생성자의 닉네임을 원한다고 가정 + private String creatorTitle; private LocalDateTime createdAt; private List options; // 투표 옵션을 위한 DTO // --- 추가될 필드 --- @@ -31,4 +32,4 @@ public static class VoteOptionDto { private Integer voteCount; private String label; } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java index fdc1808..228b909 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java @@ -33,6 +33,7 @@ public static class VoteDto { private String category; // ENUM 이름을 String으로 반환 private Long member_id; private String nickname; + private String member_title; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDateTime created_at; private Integer total_vote_count; @@ -50,4 +51,4 @@ public static class VoteOptionListDto { private Long id; private String content; } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java index e3cb3b6..b8af559 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java @@ -14,18 +14,24 @@ public class VoteResponseDto { private String category; private int totalVoteCount; private String createdAt; + private String creatorTitle; private List options; public VoteResponseDto(Vote vote) { + this(vote, null); + } + + public VoteResponseDto(Vote vote, String creatorTitle) { this.voteId = vote.getId(); this.title = vote.getTitle(); this.content = vote.getContent(); // content 필드 추가 this.category = vote.getCategory().name(); // enum to string this.totalVoteCount = vote.getTotalVoteCount(); this.createdAt = vote.getCreatedAt().toLocalDate().toString(); + this.creatorTitle = creatorTitle; this.options = vote.getVoteOptions() .stream() .map(option -> option.getContent()) // VoteOption에서 content 추출 .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java index 76df830..d375a6c 100644 --- a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java @@ -34,6 +34,8 @@ public Slice findCommentsByVoteIdSlice(Long voteId, String s QMemberVoteOption mvo = QMemberVoteOption.memberVoteOption; QVoteOption voteOption = QVoteOption.voteOption; QMemberProfile profile = QMemberProfile.memberProfile; + QMemberProfileTitle memberProfileTitle = QMemberProfileTitle.memberProfileTitle; + QTitle title = QTitle.title; NumberTemplate totalHoursAgo = Expressions.numberTemplate( Long.class, @@ -67,6 +69,7 @@ public Slice findCommentsByVoteIdSlice(Long voteId, String s comment.id, vote.id, profile.nickname, + title.name, comment.createdAt, vote.createdAt, comment.content, @@ -81,6 +84,8 @@ public Slice findCommentsByVoteIdSlice(Long voteId, String s .from(comment) .join(comment.member, member) .leftJoin(member.profile, profile) + .leftJoin(profile.memberProfileTitles, memberProfileTitle).on(memberProfileTitle.equipped.isTrue()) + .leftJoin(memberProfileTitle.title, title) .join(comment.commentGroup.vote, vote) .leftJoin(mvo).on(mvo.member.eq(member).and(mvo.vote.eq(vote))) .leftJoin(mvo.voteOption, voteOption) diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java index 7117bf7..ce55c8b 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java @@ -3,12 +3,15 @@ import com.valanse.valanse.domain.MemberProfile; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface MemberProfileRepository extends JpaRepository { Optional findByMemberId(Long id); - boolean existsByNickname(String nickname); + boolean existsByNicknameAndDeletedAtIsNull(String nickname); + + List findAllByDeletedAtIsNullOrderByPointDesc(); } diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java new file mode 100644 index 0000000..65942f0 --- /dev/null +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java @@ -0,0 +1,21 @@ +package com.valanse.valanse.repository; + +import com.valanse.valanse.domain.MemberProfileTitle; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MemberProfileTitleRepository extends JpaRepository { + Optional findByMemberProfileMemberIdAndTitleId(Long memberId, Long titleId); + + Optional findByMemberProfileIdAndTitleId(Long memberProfileId, Long titleId); + + List findAllByMemberProfileMemberIdAndEquippedTrue(Long memberId); + + Optional findByMemberProfileMemberIdAndEquippedTrue(Long memberId); + + List findAllByMemberProfileMemberId(Long memberId); + + List findAllByTitleIdAndEquippedTrue(Long titleId); +} diff --git a/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java new file mode 100644 index 0000000..10cce04 --- /dev/null +++ b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java @@ -0,0 +1,21 @@ +package com.valanse.valanse.repository; + +import com.valanse.valanse.domain.PointHistory; +import com.valanse.valanse.domain.enums.PointType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface PointHistoryRepository extends JpaRepository { + List findByMemberId(Long memberId); + + @Query("SELECT COUNT(p) FROM PointHistory p WHERE p.member.id = :memberId AND p.type = :type AND p.createdAt >= :from") + long countByMemberIdAndTypeAndCreatedAtAfter( + @Param("memberId") Long memberId, + @Param("type") PointType type, + @Param("from") LocalDateTime from + ); +} diff --git a/src/main/java/com/valanse/valanse/repository/TitleRepository.java b/src/main/java/com/valanse/valanse/repository/TitleRepository.java new file mode 100644 index 0000000..1987b48 --- /dev/null +++ b/src/main/java/com/valanse/valanse/repository/TitleRepository.java @@ -0,0 +1,24 @@ +package com.valanse.valanse.repository; + +import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TitleRepository extends JpaRepository { + List findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); + + List<Title> findAllByOrderByDisplayOrderAscIdAsc(); + + Optional<Title> findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc( + TitleAcquisitionType acquisitionType + ); + + boolean existsByCode(String code); + + boolean existsByCodeAndIdNot(String code, Long id); +} diff --git a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java index fb14faa..c8bf485 100644 --- a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java @@ -2,7 +2,11 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.valanse.valanse.domain.QCommentGroup; +import com.valanse.valanse.domain.QMember; +import com.valanse.valanse.domain.QMemberProfile; import com.valanse.valanse.domain.QVote; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.enums.VoteCategory; @@ -13,6 +17,11 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; + +import static com.valanse.valanse.domain.QComment.comment; +import static com.valanse.valanse.domain.QVoteOption.voteOption; +import static com.valanse.valanse.domain.mapping.QMemberVoteOption.memberVoteOption; @Repository @RequiredArgsConstructor @@ -20,6 +29,9 @@ public class VoteRepositoryImpl implements VoteRepositoryCustom { private final JPAQueryFactory queryFactory; private final QVote vote = QVote.vote; + private final QMember member = QMember.member; + private final QMemberProfile memberProfile = QMemberProfile.memberProfile; + private final QCommentGroup commentGroup = QCommentGroup.commentGroup; @Override public List<Vote> findVotesByCursor(String category, String sort, String cursor, int size) { @@ -30,11 +42,18 @@ public List<Vote> findVotesByCursor(String category, String sort, String cursor, } // 1. orderBy를 먼저 설정하여 어떤 정렬 기준을 사용할지 결정 - OrderSpecifier<?> orderBy; + OrderSpecifier<?>[] orderBy; if ("popular".equalsIgnoreCase(sort)) { - orderBy = vote.totalVoteCount.desc(); + orderBy = new OrderSpecifier<?>[]{ + vote.totalVoteCount.desc(), + vote.createdAt.desc(), + vote.id.desc() + }; } else { // 기본값 또는 latest 정렬 - orderBy = vote.createdAt.desc(); + orderBy = new OrderSpecifier<?>[]{ + vote.createdAt.desc(), + vote.id.desc() + }; } // 2. cursor 값에 따라 Predicate를 생성 @@ -44,10 +63,19 @@ public List<Vote> findVotesByCursor(String category, String sort, String cursor, String[] parts = cursor.split("_"); Integer cursorTotalVoteCount = Integer.parseInt(parts[0]); LocalDateTime cursorCreatedAt = LocalDateTime.parse(parts[1]); + Long cursorId = Long.parseLong(parts[2]); cursorPredicate = vote.totalVoteCount.lt(cursorTotalVoteCount) - .or(vote.totalVoteCount.eq(cursorTotalVoteCount).and(vote.createdAt.lt(cursorCreatedAt))); + .or(vote.totalVoteCount.eq(cursorTotalVoteCount) + .and(vote.createdAt.lt(cursorCreatedAt))) + .or(vote.totalVoteCount.eq(cursorTotalVoteCount) + .and(vote.createdAt.eq(cursorCreatedAt)) + .and(vote.id.lt(cursorId))); } else { // latest - cursorPredicate = vote.createdAt.lt(LocalDateTime.parse(cursor)); + String[] parts = cursor.split("_"); + LocalDateTime cursorCreatedAt = LocalDateTime.parse(parts[0]); + Long cursorId = Long.parseLong(parts[1]); + cursorPredicate = vote.createdAt.lt(cursorCreatedAt) + .or(vote.createdAt.eq(cursorCreatedAt).and(vote.id.lt(cursorId))); } } @@ -55,11 +83,83 @@ public List<Vote> findVotesByCursor(String category, String sort, String cursor, whereConditions.add(cursorPredicate); } - return queryFactory - .selectFrom(vote) + List<Long> voteIds = queryFactory + .select(vote.id) + .from(vote) .where(whereConditions.toArray(new Predicate[0])) .orderBy(orderBy) .limit(size + 1) // 다음 페이지 존재 여부 확인 .fetch(); + + if (voteIds.isEmpty()) { + return new ArrayList<>(); + } + + return queryFactory + .selectFrom(vote) + .distinct() + .leftJoin(vote.member, member).fetchJoin() + .leftJoin(member.profile, memberProfile).fetchJoin() + .leftJoin(vote.commentGroup, commentGroup).fetchJoin() + .leftJoin(vote.voteOptions, voteOption).fetchJoin() + .where(vote.id.in(voteIds)) + .orderBy(orderBy) + .fetch(); } -} \ No newline at end of file + + @Override + public Optional<Vote> findHotIssueVote() { + return Optional.ofNullable( + queryFactory + .selectFrom(vote) + .leftJoin(vote.commentGroup, commentGroup) + .orderBy( + vote.totalVoteCount + .add(commentGroup.totalCommentCount.coalesce(0)) + .desc(), + vote.createdAt.desc() // 점수 같을 때 최신순 + ) + .fetchFirst()); + } + + @Override + public Optional<Vote> findTrendingVote(LocalDateTime from, LocalDateTime to) { + return Optional.ofNullable( + queryFactory + .selectFrom(vote) + .leftJoin(vote.commentGroup, commentGroup) + .where( + // 기간 내 댓글 존재 + JPAExpressions + .selectOne() + .from(comment) + .where( + comment.commentGroup.eq(commentGroup), + comment.createdAt.between(from, to), + comment.deletedAt.isNull() + ) + .exists() + .or( + // 기간 내 투표 존재 + JPAExpressions + .selectOne() + .from(memberVoteOption) + .join(memberVoteOption.voteOption, voteOption) + .where( + voteOption.vote.eq(vote), + memberVoteOption.createdAt.between(from, to) + ) + .exists() + ) + ) + .orderBy( + vote.totalVoteCount + .add(commentGroup.totalCommentCount.coalesce(0)) + .desc(), + vote.createdAt.desc() // 점수 같을 때 최신순 + ) + .fetchFirst()); + + } + +} diff --git a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java index 9febbf7..26a931a 100644 --- a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java +++ b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java @@ -2,8 +2,12 @@ import com.valanse.valanse.domain.Vote; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface VoteRepositoryCustom { List<Vote> findVotesByCursor(String category, String sort, String cursor, int size); -} \ No newline at end of file + Optional<Vote> findHotIssueVote(); + Optional<Vote> findTrendingVote(LocalDateTime from, LocalDateTime to); +} diff --git a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java index fc8773b..bdd271a 100644 --- a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java @@ -4,10 +4,12 @@ import com.querydsl.core.types.dsl.NumberTemplate; import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.*; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.dto.Comment.*; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -34,6 +36,8 @@ public class CommentServiceImpl implements CommentService { private final CommentGroupRepository commentGroupRepository; private final CommentRepository commentRepository; private final MemberProfileRepository memberProfileRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; + private final PointService pointService; @Override public void deleteMyComment(Member member, Long commentId) { @@ -137,7 +141,14 @@ public Long createComment(Long voteId, Long userId, CommentPostRequest request) commentGroupRepository.save(commentGroup); } - return commentRepository.save(comment).getId(); + Long savedCommentId = commentRepository.save(comment).getId(); + + // 댓글 작성 포인트 지급 (부모 댓글일 때만) + if (request.getParentId() == null) { + pointService.givePoint(userId, PointType.COMMENT_CREATE); + } + + return savedCommentId; } @Override @@ -217,6 +228,7 @@ public List<CommentReplyResponseDto> getReplies(Member loginUser, Long voteId, L return CommentReplyResponseDto.builder() .id(reply.getId()) .nickname(profile.getNickname()) + .title(getEquippedTitleName(reply.getMember().getId())) .createdAt(reply.getCreatedAt()) .content(reply.getContent()) .likeCount(reply.getLikeCount()) @@ -230,4 +242,11 @@ public List<CommentReplyResponseDto> getReplies(Member loginUser, Long voteId, L }) .collect(Collectors.toList()); } -} \ No newline at end of file + + private String getEquippedTitleName(Long memberId) { + return memberProfileTitleRepository.findByMemberProfileMemberIdAndEquippedTrue(memberId) + .map(MemberProfileTitle::getTitle) + .map(Title::getName) + .orElse(null); + } +} diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java index 1da8597..bf574c7 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java @@ -5,13 +5,15 @@ import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; +import java.util.List; + public interface MemberProfileService { void saveOrUpdateProfile(MemberProfileRequest dto); MemberProfileResponse getProfile(); MemberMyPageResponse getMyProfile(); - boolean isAvailableNickname(String nickname); // 중복 아님 → true - boolean isMeaningfulNickname(String nickname); // 무의미하지 않음 → true - boolean isCleanNickname(String nickname); // 비속어 아님 → true + boolean isAvailableNickname(String nickname); + boolean isMeaningfulNickname(String nickname); + boolean isCleanNickname(String nickname); } diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index 77a59d8..6016354 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -2,11 +2,18 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.TitleRepository; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -22,6 +29,9 @@ public class MemberProfileServiceImpl implements MemberProfileService { private final MemberRepository memberRepository; private final MemberProfileRepository memberProfileRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; + private final TitleRepository titleRepository; + private final PointService pointService; @Override public void saveOrUpdateProfile(MemberProfileRequest dto) { @@ -48,7 +58,7 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { // 닉네임이 변경되었을 때만 중복 체크 if (!profile.getNickname().equals(dto.nickname())) { - if (memberProfileRepository.existsByNickname(dto.nickname())) { + if (memberProfileRepository.existsByNicknameAndDeletedAtIsNull(dto.nickname())) { throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); } } @@ -57,8 +67,8 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { profile.update(dto.nickname(), dto.gender(), dto.age(), dto.mbtiIe(), dto.mbtiTf(), dto.mbti()); memberProfileRepository.save(profile); } else { - // ✅ 신규 생성: 무조건 중복 체크 - if (memberProfileRepository.existsByNickname(dto.nickname())) { + // ✅ 신규 생성: 무조건 중복 체크 (soft delete된 회원 닉네임 제외) + if (memberProfileRepository.existsByNicknameAndDeletedAtIsNull(dto.nickname())) { throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); } @@ -73,7 +83,11 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { .mbti(dto.mbti()) .build(); - memberProfileRepository.save(newProfile); + MemberProfile savedProfile = memberProfileRepository.save(newProfile); + grantAndEquipDefaultTitle(savedProfile != null ? savedProfile : newProfile); + + // 신규 프로필 생성 시 회원가입 포인트 지급 + pointService.givePoint(userId, PointType.SIGN_UP); } } @@ -99,7 +113,9 @@ public MemberProfileResponse getProfile() { profile.getMbtiIe() != null ? profile.getMbtiIe().name() : null, profile.getMbtiTf() != null ? profile.getMbtiTf().name() : null, profile.getMbti() != null ? profile.getMbti() : null, - member.getRole() != null ? member.getRole() : null + member.getRole() != null ? member.getRole() : null, + getEquippedTitleName(profile), + profile.getPoint() ); return new MemberProfileResponse(info); @@ -107,7 +123,7 @@ public MemberProfileResponse getProfile() { @Override public boolean isAvailableNickname(String nickname) { - return !memberProfileRepository.existsByNickname(nickname); + return !memberProfileRepository.existsByNicknameAndDeletedAtIsNull(nickname); } @Override @@ -209,9 +225,40 @@ public MemberMyPageResponse getMyProfile() { profile.getNickname(), profile.getGender() != null ? profile.getGender().name() : null, profile.getAge() != null ? profile.getAge().name() : null, - profile.getMbti() != null ? profile.getMbti() : null + profile.getMbti() != null ? profile.getMbti() : null, + getEquippedTitleName(profile), + profile.getPoint() ); return new MemberMyPageResponse(info); } -} \ No newline at end of file + + private String getEquippedTitleName(MemberProfile profile) { + return profile.getMemberProfileTitles().stream() + .filter(MemberProfileTitle::isEquipped) + .map(MemberProfileTitle::getTitle) + .filter(title -> title != null) + .map(title -> title.getName()) + .findFirst() + .orElse(null); + } + + private void grantAndEquipDefaultTitle(MemberProfile profile) { + Optional<Title> defaultTitle = titleRepository + .findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT); + + if (defaultTitle == null || defaultTitle.isEmpty()) { + return; + } + + MemberProfileTitle profileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(defaultTitle.get()) + .equipped(true) + .build(); + + memberProfileTitleRepository.save(profileTitle); + profile.getMemberProfileTitles().add(profileTitle); + } + +} diff --git a/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java index 8fedc4b..c7effea 100644 --- a/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java @@ -2,7 +2,10 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; @@ -14,6 +17,8 @@ @RequiredArgsConstructor public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; + private final MemberProfileRepository memberProfileRepository; + private final PointService pointService; @Transactional(readOnly = true) @Override @@ -33,6 +38,10 @@ public Member createOauth(String socialId, String email, String name, String pro .kakaoRefreshToken(refresh_token) .build(); memberRepository.save(member); + + // 회원가입 포인트 지급 (프로필 생성 후 지급되므로 여기선 기록만 남김) + // 실제 포인트는 프로필 저장 시점에 지급 (MemberProfileServiceImpl 참고) + return member; } @@ -43,6 +52,11 @@ public Member deleteMemberById() { .orElseThrow(() -> new ApiException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND)); member.softDelete(); // Soft delete 처리 + + // MemberProfile도 함께 soft delete (닉네임 중복 방지) + memberProfileRepository.findByMemberId(userId) + .ifPresent(profile -> profile.softDelete()); + return memberRepository.save(member); // 삭제된 상태로 저장 } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointService.java b/src/main/java/com/valanse/valanse/service/PointService/PointService.java new file mode 100644 index 0000000..bf62b2e --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/PointService/PointService.java @@ -0,0 +1,10 @@ +package com.valanse.valanse.service.PointService; + +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; + +public interface PointService { + void givePoint(Long memberId, PointType type); + void recordPointUsage(Long memberId, long amount, PointType type); + PointHistoryResponse getPointHistory(Long memberId); +} diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java new file mode 100644 index 0000000..3070d52 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -0,0 +1,191 @@ +package com.valanse.valanse.service.PointService; + +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.PointHistory; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.PointHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional +public class PointServiceImpl implements PointService { + + private final MemberRepository memberRepository; + private final MemberProfileRepository memberProfileRepository; + private final PointHistoryRepository pointHistoryRepository; + + // 포인트 정책 + private static final long SIGN_UP_POINT = 40L; + private static final long POST_CREATE_POINT = 5L; + private static final long COMMENT_CREATE_POINT = 1L; + private static final long POST_VOTED_POINT = 1L; + private static final long HOT_ISSUE_POINT = 50L; + + // 댓글 포인트 일일 최대 획득 횟수 + private static final long COMMENT_DAILY_LIMIT = 3L; + + // 날짜 포맷터 + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public void givePoint(Long memberId, PointType type) { + Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + MemberProfile profile = memberProfileRepository.findByMemberId(memberId) + .orElseThrow(() -> new IllegalArgumentException("프로필을 찾을 수 없습니다.")); + + long amount = switch (type) { + case SIGN_UP -> SIGN_UP_POINT; + case POST_CREATE -> POST_CREATE_POINT; + case COMMENT_CREATE -> { + // 오늘 자정부터 현재까지 댓글 포인트 획득 횟수 체크 + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + long todayCount = pointHistoryRepository.countByMemberIdAndTypeAndCreatedAtAfter( + memberId, PointType.COMMENT_CREATE, todayStart + ); + if (todayCount >= COMMENT_DAILY_LIMIT) { + yield 0L; // 일일 제한 초과 시 0 포인트 지급 + } + yield COMMENT_CREATE_POINT; + } + case POST_VOTED -> POST_VOTED_POINT; + case HOT_ISSUE -> HOT_ISSUE_POINT; + case TITLE_PURCHASE -> throw new IllegalArgumentException("포인트 지급 타입이 아닙니다."); + }; + + // 포인트가 0보다 클 때만 포인트 지급 및 히스토리 저장 + if (amount > 0) { + profile.addPoint(amount); + memberProfileRepository.save(profile); + + PointHistory history = PointHistory.builder() + .member(member) + .amount(amount) + .remainingPoint(profile.getPoint()) + .type(type) + .build(); + pointHistoryRepository.save(history); + } + } + + @Override + public void recordPointUsage(Long memberId, long amount, PointType type) { + if (amount <= 0) { + throw new IllegalArgumentException("사용 포인트는 0보다 커야 합니다."); + } + + Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + MemberProfile profile = memberProfileRepository.findByMemberId(memberId) + .orElseThrow(() -> new IllegalArgumentException("프로필을 찾을 수 없습니다.")); + + PointHistory history = PointHistory.builder() + .member(member) + .amount(-amount) + .remainingPoint(profile.getPoint()) + .type(type) + .build(); + pointHistoryRepository.save(history); + } + + @Override + @Transactional(readOnly = true) + public PointHistoryResponse getPointHistory(Long memberId) { + // 회원 존재 여부 확인 + memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + // 포인트 히스토리 조회 (최신순으로 정렬) + List<PointHistory> histories = pointHistoryRepository.findByMemberId(memberId); + Map<Long, Long> fallbackRemainingPoints = calculateRemainingPoints(histories); + + // DTO로 변환 + List<PointHistoryResponse.PointHistoryItem> historyItems = histories.stream() + .sorted((h1, h2) -> { + // null 값 처리: null은 가장 오래된 것으로 간주 + if (h1.getCreatedAt() == null && h2.getCreatedAt() == null) return 0; + if (h1.getCreatedAt() == null) return 1; + if (h2.getCreatedAt() == null) return -1; + return h2.getCreatedAt().compareTo(h1.getCreatedAt()); // 최신순 정렬 + }) + .map(history -> new PointHistoryResponse.PointHistoryItem( + history.getId(), + history.getAmount(), + history.getRemainingPoint() != null + ? history.getRemainingPoint() + : fallbackRemainingPoints.get(history.getId()), + history.getType(), + getPointTypeDescription(history.getType()), + formatCreatedAt(history.getCreatedAt()) + )) + .toList(); + + return new PointHistoryResponse(historyItems); + } + + private Map<Long, Long> calculateRemainingPoints(List<PointHistory> histories) { + Map<Long, Long> remainingPoints = new HashMap<>(); + long balance = 0L; + + List<PointHistory> oldestFirstHistories = histories.stream() + .sorted((h1, h2) -> { + if (h1.getCreatedAt() == null && h2.getCreatedAt() == null) { + return compareId(h1, h2); + } + if (h1.getCreatedAt() == null) return -1; + if (h2.getCreatedAt() == null) return 1; + + int createdAtCompare = h1.getCreatedAt().compareTo(h2.getCreatedAt()); + return createdAtCompare != 0 ? createdAtCompare : compareId(h1, h2); + }) + .toList(); + + for (PointHistory history : oldestFirstHistories) { + balance += history.getAmount(); + remainingPoints.put(history.getId(), balance); + } + + return remainingPoints; + } + + private int compareId(PointHistory h1, PointHistory h2) { + if (h1.getId() == null && h2.getId() == null) return 0; + if (h1.getId() == null) return -1; + if (h2.getId() == null) return 1; + return h1.getId().compareTo(h2.getId()); + } + + private String getPointTypeDescription(PointType type) { + return switch (type) { + case SIGN_UP -> "회원가입"; + case POST_CREATE -> "게시글 작성"; + case COMMENT_CREATE -> "댓글 작성"; + case POST_VOTED -> "투표 참여"; + case HOT_ISSUE -> "핫이슈"; + case TITLE_PURCHASE -> "칭호 구매"; + }; + } + + private String formatCreatedAt(LocalDateTime createdAt) { + if (createdAt == null) { + return null; + } + return createdAt.format(DATE_TIME_FORMATTER); + } +} diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java new file mode 100644 index 0000000..073a687 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -0,0 +1,29 @@ +package com.valanse.valanse.service.TitleService; + +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleAdminResponse; +import com.valanse.valanse.dto.Title.TitleDeleteResponse; +import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.dto.Title.TitleListResponse; +import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateResponse; + +import java.util.List; + +public interface TitleService { + TitleListResponse getTitleList(Long userId); + + TitleEquipResponse equipTitle(Long userId, Long titleId); + + TitlePurchaseResponse purchaseTitle(Long userId, Long titleId); + + List<TitleAdminResponse> getTitleListForAdmin(Long adminUserId); + + TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request); + + TitleUpdateResponse updateTitle(Long adminUserId, Long titleId, TitleUpdateRequest request); + + TitleDeleteResponse deleteTitle(Long adminUserId, Long titleId); +} diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java new file mode 100644 index 0000000..f226c02 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -0,0 +1,415 @@ +package com.valanse.valanse.service.TitleService; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleAdminResponse; +import com.valanse.valanse.dto.Title.TitleDeleteResponse; +import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.dto.Title.TitleListResponse; +import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateResponse; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; +import com.valanse.valanse.repository.TitleRepository; +import com.valanse.valanse.service.PointService.PointService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class TitleServiceImpl implements TitleService { + + private final MemberRepository memberRepository; + private final MemberProfileRepository memberProfileRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; + private final TitleRepository titleRepository; + private final PointService pointService; + + @Override + public TitleListResponse getTitleList(Long userId) { + MemberProfile profile = memberProfileRepository.findByMemberId(userId) + .orElseThrow(() -> new IllegalArgumentException("프로필이 존재하지 않습니다.")); + + List<Title> titles = titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); + List<MemberProfileTitle> profileTitles = memberProfileTitleRepository.findAllByMemberProfileMemberId(userId); + boolean hasEquippedTitle = profileTitles.stream().anyMatch(MemberProfileTitle::isEquipped); + Map<Long, MemberProfileTitle> ownedTitleMap = profileTitles.stream() + .collect(Collectors.toMap( + profileTitle -> profileTitle.getTitle().getId(), + Function.identity(), + (current, ignored) -> current + )); + + List<MemberProfileTitle> defaultTitlesToSave = new ArrayList<>(); + for (Title title : titles) { + if (!title.isDefaultTitle() || ownedTitleMap.containsKey(title.getId())) { + continue; + } + + MemberProfileTitle profileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .equipped(!hasEquippedTitle) + .build(); + if (!hasEquippedTitle) { + hasEquippedTitle = true; + } + defaultTitlesToSave.add(profileTitle); + } + + if (!defaultTitlesToSave.isEmpty()) { + memberProfileTitleRepository.saveAll(defaultTitlesToSave) + .forEach(profileTitle -> ownedTitleMap.put(profileTitle.getTitle().getId(), profileTitle)); + } + + List<TitleListResponse.TitleSummaryResponse> defaultTitles = new ArrayList<>(); + List<TitleListResponse.TitleSummaryResponse> ownedTitles = new ArrayList<>(); + List<TitleListResponse.TitleSummaryResponse> lockedTitles = new ArrayList<>(); + + for (Title title : titles) { + MemberProfileTitle profileTitle = ownedTitleMap.get(title.getId()); + boolean owned = profileTitle != null || title.isDefaultTitle(); + boolean locked = !owned; + + TitleListResponse.TitleSummaryResponse response = toTitleSummary(title, profileTitle, owned, locked); + if (title.isDefaultTitle()) { + defaultTitles.add(response); + } else if (owned) { + ownedTitles.add(response); + } else { + lockedTitles.add(response); + } + } + + return new TitleListResponse(defaultTitles, ownedTitles, lockedTitles); + } + + @Override + public TitleEquipResponse equipTitle(Long userId, Long titleId) { + memberProfileRepository.findByMemberId(userId) + .orElseThrow(() -> new IllegalArgumentException("프로필이 존재하지 않습니다.")); + + MemberProfileTitle targetTitle = memberProfileTitleRepository + .findByMemberProfileMemberIdAndTitleId(userId, titleId) + .orElseThrow(() -> new IllegalArgumentException("보유하지 않은 칭호입니다.")); + + if (!targetTitle.getTitle().isActive()) { + throw new IllegalArgumentException("장착할 수 없는 칭호입니다."); + } + + memberProfileTitleRepository.findAllByMemberProfileMemberIdAndEquippedTrue(userId) + .forEach(MemberProfileTitle::unequip); + targetTitle.equip(); + + return new TitleEquipResponse( + targetTitle.getTitle().getId(), + targetTitle.getTitle().getName(), + targetTitle.isEquipped() + ); + } + + @Override + public TitlePurchaseResponse purchaseTitle(Long userId, Long titleId) { + MemberProfile profile = memberProfileRepository.findByMemberId(userId) + .orElseThrow(() -> new IllegalArgumentException("프로필이 존재하지 않습니다.")); + + Title title = titleRepository.findById(titleId) + .orElseThrow(() -> new IllegalArgumentException("칭호가 존재하지 않습니다.")); + + if (!title.isPointPurchaseTitle() || !title.isActive()) { + throw new IllegalArgumentException("구매할 수 없는 칭호입니다."); + } + + memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(userId, titleId) + .ifPresent(ownedTitle -> { + throw new IllegalArgumentException("이미 보유한 칭호입니다."); + }); + + if (!profile.hasEnoughPoint(title.getPrice())) { + throw new IllegalArgumentException("포인트가 부족합니다. (필요포인트 " + title.getPrice() + "P 필요)"); + } + + profile.subtractPoint(title.getPrice()); + pointService.recordPointUsage(userId, title.getPrice(), PointType.TITLE_PURCHASE); + memberProfileTitleRepository.save(MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build()); + + return new TitlePurchaseResponse( + title.getId(), + title.getName(), + true, + profile.getPoint() + ); + } + + @Override + @Transactional(readOnly = true) + public List<TitleAdminResponse> getTitleListForAdmin(Long adminUserId) { + validateAdmin(adminUserId); + + return titleRepository.findAllByOrderByDisplayOrderAscIdAsc().stream() + .map(this::toTitleAdminResponse) + .toList(); + } + + @Override + public TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request) { + validateAdmin(adminUserId); + + validateCreateRequest(request); + + String code = request.code().trim(); + if (titleRepository.existsByCode(code)) { + throw new ApiException("이미 존재하는 칭호 코드입니다.", HttpStatus.BAD_REQUEST); + } + + Title title = Title.builder() + .code(code) + .name(request.title().trim()) + .description(trimToNull(request.description())) + .price(request.price() == null ? 0L : request.price()) + .tier(request.tier()) + .acquisitionType(request.acquisitionType()) + .requirementText(trimToNull(request.requirementText())) + .active(request.active() == null || request.active()) + .displayOrder(request.displayOrder() == null ? 0 : request.displayOrder()) + .build(); + + Title savedTitle = titleRepository.save(title); + return toTitleCreateResponse(savedTitle); + } + + @Override + public TitleUpdateResponse updateTitle(Long adminUserId, Long titleId, TitleUpdateRequest request) { + validateAdmin(adminUserId); + + validateUpdateRequest(request); + + Title title = titleRepository.findById(titleId) + .orElseThrow(() -> new ApiException("칭호가 존재하지 않습니다.", HttpStatus.NOT_FOUND)); + + String code = request.code().trim(); + if (titleRepository.existsByCodeAndIdNot(code, titleId)) { + throw new ApiException("이미 존재하는 칭호 코드입니다.", HttpStatus.BAD_REQUEST); + } + + title.update( + code, + request.title().trim(), + trimToNull(request.description()), + request.price() == null ? 0L : request.price(), + request.tier(), + request.acquisitionType(), + trimToNull(request.requirementText()), + request.active() == null || request.active(), + request.displayOrder() == null ? 0 : request.displayOrder() + ); + + return toTitleUpdateResponse(title); + } + + @Override + public TitleDeleteResponse deleteTitle(Long adminUserId, Long titleId) { + validateAdmin(adminUserId); + + Title title = titleRepository.findById(titleId) + .orElseThrow(() -> new ApiException("칭호가 존재하지 않습니다.", HttpStatus.NOT_FOUND)); + + if (!title.isActive()) { + throw new ApiException("이미 삭제된 칭호입니다.", HttpStatus.BAD_REQUEST); + } + if (title.isDefaultTitle()) { + throw new ApiException("기본 칭호는 삭제할 수 없습니다.", HttpStatus.BAD_REQUEST); + } + + Title fallbackTitle = titleRepository + .findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT) + .orElseThrow(() -> new ApiException("기본 칭호가 존재하지 않습니다.", HttpStatus.INTERNAL_SERVER_ERROR)); + + List<MemberProfileTitle> equippedTitles = memberProfileTitleRepository.findAllByTitleIdAndEquippedTrue(titleId); + for (MemberProfileTitle equippedTitle : equippedTitles) { + equippedTitle.unequip(); + MemberProfile profile = equippedTitle.getMemberProfile(); + MemberProfileTitle fallbackProfileTitle = memberProfileTitleRepository + .findByMemberProfileIdAndTitleId(profile.getId(), fallbackTitle.getId()) + .orElseGet(() -> memberProfileTitleRepository.save(MemberProfileTitle.builder() + .memberProfile(profile) + .title(fallbackTitle) + .build())); + fallbackProfileTitle.equip(); + } + title.deactivate(); + + return new TitleDeleteResponse( + title.getId(), + title.getName(), + fallbackTitle.getId(), + fallbackTitle.getName(), + equippedTitles.size(), + title.isActive() + ); + } + + private void validateAdmin(Long adminUserId) { + Member admin = memberRepository.findByIdAndDeletedAtIsNull(adminUserId) + .orElseThrow(() -> new ApiException("회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + if (admin.getRole() != Role.ADMIN) { + throw new ApiException("관리자만 접근 가능합니다.", HttpStatus.FORBIDDEN); + } + } + + private TitleListResponse.TitleSummaryResponse toTitleSummary( + Title title, + MemberProfileTitle profileTitle, + boolean owned, + boolean locked + ) { + return new TitleListResponse.TitleSummaryResponse( + title.getId(), + title.getName(), + title.getDescription(), + title.getTier(), + title.getAcquisitionType(), + owned, + profileTitle != null && profileTitle.isEquipped(), + locked, + title.getPrice(), + title.getRequirementText(), + locked ? getLockReason(title) : null + ); + } + + private String getLockReason(Title title) { + if (title.getRequirementText() != null && !title.getRequirementText().isBlank()) { + return title.getRequirementText(); + } + if (title.isPointPurchaseTitle()) { + return title.getPrice() + "P 필요"; + } + return "획득 조건을 달성해야 합니다."; + } + + private TitleAdminResponse toTitleAdminResponse(Title title) { + return new TitleAdminResponse( + title.getId(), + title.getCode(), + title.getName(), + title.getDescription(), + title.getPrice(), + title.getTier(), + title.getAcquisitionType(), + title.getRequirementText(), + title.getDisplayOrder() + ); + } + + private void validateCreateRequest(TitleCreateRequest request) { + if (request == null) { + throw new ApiException("칭호 생성 요청이 비어있습니다.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.code())) { + throw new ApiException("칭호 코드를 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.title())) { + throw new ApiException("칭호 이름을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.tier() == null) { + throw new ApiException("칭호 등급을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.acquisitionType() == null) { + throw new ApiException("칭호 획득 방식을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.price() != null && request.price() < 0) { + throw new ApiException("칭호 가격은 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + if (request.displayOrder() != null && request.displayOrder() < 0) { + throw new ApiException("칭호 표시 순서는 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private void validateUpdateRequest(TitleUpdateRequest request) { + if (request == null) { + throw new ApiException("칭호 수정 요청이 비어있습니다.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.code())) { + throw new ApiException("칭호 코드를 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.title())) { + throw new ApiException("칭호 이름을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.tier() == null) { + throw new ApiException("칭호 등급을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.acquisitionType() == null) { + throw new ApiException("칭호 획득 방식을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.price() != null && request.price() < 0) { + throw new ApiException("칭호 가격은 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + if (request.displayOrder() != null && request.displayOrder() < 0) { + throw new ApiException("칭호 표시 순서는 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private TitleCreateResponse toTitleCreateResponse(Title title) { + return new TitleCreateResponse( + title.getId(), + title.getCode(), + title.getName(), + title.getDescription(), + title.getPrice(), + title.getTier(), + title.getAcquisitionType(), + title.getRequirementText(), + title.isActive(), + title.getDisplayOrder() + ); + } + + private TitleUpdateResponse toTitleUpdateResponse(Title title) { + return new TitleUpdateResponse( + title.getId(), + title.getCode(), + title.getName(), + title.getDescription(), + title.getPrice(), + title.getTier(), + title.getAcquisitionType(), + title.getRequirementText(), + title.isActive(), + title.getDisplayOrder() + ); + } +} diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 7c84bc4..782f2c6 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -3,17 +3,19 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.*; import com.valanse.valanse.domain.enums.PinType; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteCategory; import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.domain.mapping.MemberVoteOption; import com.valanse.valanse.dto.Vote.*; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; // import 추가 +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; // 기존 코드에 있었으므로 유지 import java.util.List; // 기존 코드에 있었으므로 유지 @@ -26,11 +28,13 @@ public class VoteServiceImpl implements VoteService { private final VoteRepository voteRepository; - private final MemberProfileRepository memberProfileRepository; // MemberProfile 정보 조회를 위해 필요 - private final MemberRepository memberRepository; // processVote 메서드에서 Member 조회를 위해 추가 - private final VoteOptionRepository voteOptionRepository; // processVote 메서드에서 VoteOption 조회를 위해 추가 - private final MemberVoteOptionRepository memberVoteOptionRepository; // processVote 메서드에서 MemberVoteOption 조회를 위해 추가 + private final MemberProfileRepository memberProfileRepository; + private final MemberRepository memberRepository; + private final VoteOptionRepository voteOptionRepository; + private final MemberVoteOptionRepository memberVoteOptionRepository; private final CommentGroupRepository commentGroupRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; + private final PointService pointService; //작은 민지가 구현한 것 @Override @@ -55,7 +59,9 @@ public List<VoteResponseDto> getMyCreatedVotes(Long memberId, String sort, VoteC voteRepository.findAllByMemberAndCategoryOrderByCreatedAtAsc(member, category); } - return votes.stream().map(VoteResponseDto::new).collect(Collectors.toList()); + return votes.stream() + .map(vote -> new VoteResponseDto(vote, getEquippedTitleName(vote.getMember()))) + .collect(Collectors.toList()); } @Override @@ -80,12 +86,13 @@ public List<VoteResponseDto> getMyVotedVotes(Long memberId, String sort, VoteCat voteRepository.findAllByMemberVotedAndCategoryOrderByCreatedAtAsc(member, category); } - return votes.stream().map(VoteResponseDto::new).collect(Collectors.toList()); + return votes.stream() + .map(vote -> new VoteResponseDto(vote, getEquippedTitleName(vote.getMember()))) + .collect(Collectors.toList()); } //여기서부터 영서 코드 @Override - @Transactional public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 // 0. 고정 게시물이 있다면 반환. Optional<Vote> pinnedHot = voteRepository.findByPinType(PinType.HOT); @@ -98,24 +105,14 @@ public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 LocalDateTime now = LocalDateTime.now(); LocalDateTime yesterdayStart = now.minusDays(1).withHour(0).withMinute(0).withSecond(0); - // 1. 먼저 모든 투표의 반응성 점수를 업데이트 (실시간 계산) - List<Vote> allVotes = voteRepository.findAll(); - for(Vote vote : allVotes) { - vote.updateReactivityScore(); // Vote 엔티티에 추가한 메서드 - voteRepository.save(vote); - } - - // 2. 작일 동안 반응성이 가장 높은 투표 조회 시도 - Optional<Vote> yesterdayHotIssue = voteRepository - .findTopByCreatedAtBetweenOrderByReactivityScoreDescCreatedAtDesc(yesterdayStart, now); - + // 작일 동안 반응성이 가장 높은 투표 조회 시도 + Optional<Vote> yesterdayHotIssue = voteRepository.findTrendingVote(yesterdayStart, now); Vote hotIssueVote; if (yesterdayHotIssue.isPresent()) { // 작일 반응성 데이터가 있는 경우 hotIssueVote = yesterdayHotIssue.get(); } else { - // 작일 반응성 데이터가 없는 경우 → 전체 누적 반응성 기준 조회 - hotIssueVote = voteRepository.findTopByOrderByReactivityScoreDescCreatedAtDesc() + hotIssueVote = voteRepository.findHotIssueVote() .orElseThrow(() -> new ApiException("핫이슈 투표를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); } @@ -125,7 +122,6 @@ public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 // 인기 급상승 토픽 @Override - @Transactional public HotIssueVoteResponse getTrendingVote() { // 0. 고정 게시물이 있다면 반환. Optional<Vote> pinnedTrending = voteRepository.findByPinType(PinType.TRENDING); @@ -137,19 +133,13 @@ public HotIssueVoteResponse getTrendingVote() { } - // 7일 이전 시간 계산 - LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); - - // 1. 모든 투표의 반응성 점수 업데이트 - List<Vote> allVotes = voteRepository.findAll(); - for(Vote vote : allVotes) { - vote.updateReactivityScore(); - voteRepository.save(vote); - } + // 7일 이전 시간 계산 + LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); + LocalDateTime now = LocalDateTime.now(); - // 2. 최근 7일 내 반응성이 가장 높은 투표 조회 + // 최근 7일 내 반응성이 가장 높은 투표 조회 Optional<Vote> recentTrendingVote = voteRepository - .findTopByCreatedAtBetweenOrderByReactivityScoreDescCreatedAtDesc(sevenDaysAgo, LocalDateTime.now()); + .findTrendingVote(sevenDaysAgo, now); Vote trendingVote; if (recentTrendingVote.isPresent()) { @@ -157,7 +147,7 @@ public HotIssueVoteResponse getTrendingVote() { trendingVote = recentTrendingVote.get(); } else { // 7일 내 데이터가 없는 경우 - 이전 데이터 유지 (전체 기간에서 가장 높은 반응성) - trendingVote = voteRepository.findTopByOrderByReactivityScoreDescCreatedAtDesc() + trendingVote = voteRepository.findHotIssueVote() .orElseThrow(() -> new ApiException("인기 급상승 투표를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); } @@ -180,7 +170,6 @@ public VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOpti .orElseThrow(() -> new ApiException("투표 선택지가 존재하지 않습니다.", HttpStatus.NOT_FOUND)); // 2. 사용자가 이 투표에 대해 이전에 투표한 선택지가 있는지 확인합니다. - // ERD의 member_vote_option 테이블을 활용 [cite: image_02691a.jpg] Optional<MemberVoteOption> existingVote = memberVoteOptionRepository.findByMemberIdAndVoteId(userId, voteId); boolean isVoted; // 최종적으로 투표가 되어 있는지 여부 (응답 DTO에 사용) @@ -194,11 +183,11 @@ public VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOpti if (oldVoteOption.getId().equals(voteOptionId)) { // 3-1. 동일한 선택지를 다시 클릭한 경우: 투표 취소 - // member_vote_option에서 해당 기록을 삭제 [cite: image_0268de.png] + // member_vote_option에서 해당 기록을 삭제 memberVoteOptionRepository.delete(oldMemberVoteOption); - // 기존 선택지의 투표 수 감소 [cite: image_0268de.png] + // 기존 선택지의 투표 수 감소 oldVoteOption.setVoteCount(oldVoteOption.getVoteCount() - 1); - // 전체 투표 수 감소 [cite: image_0268de.png] + // 전체 투표 수 감소a vote.setTotalVoteCount(vote.getTotalVoteCount() - 1); isVoted = false; // 투표가 취소되었으므로 false @@ -229,19 +218,21 @@ public VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOpti } } else { // 4. 이전에 투표한 기록이 없는 경우: 새로운 투표 기록 - // 새로운 member_vote_option 기록 생성 및 저장 MemberVoteOption newMemberVoteOption = MemberVoteOption.builder() .member(member) .vote(vote) .voteOption(newVoteOption) .build(); memberVoteOptionRepository.save(newMemberVoteOption); - // 선택지 투표 수 증가 newVoteOption.setVoteCount(newVoteOption.getVoteCount() + 1); - // 전체 투표 수 증가 vote.setTotalVoteCount(vote.getTotalVoteCount() + 1); - isVoted = true; // 새로운 옵션에 투표했으므로 true + // 게시물 작성자에게 투표 참여 포인트 지급 + if (vote.getMember() != null && !vote.getMember().getId().equals(userId)) { + pointService.givePoint(vote.getMember().getId(), PointType.POST_VOTED); + } + + isVoted = true; updatedTotalVoteCount = vote.getTotalVoteCount(); updatedVoteOptionCount = newVoteOption.getVoteCount(); } @@ -271,6 +262,7 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { // MemberProfile의 nickname을 가져오도록 수정 String creatorNickname = null; + String creatorTitle = getEquippedTitleName(vote.getMember()); if (vote.getMember() != null && vote.getMember().getProfile() != null) { creatorNickname = vote.getMember().getProfile().getNickname(); } @@ -295,16 +287,8 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { SecurityContextHolder.getContext().getAuthentication().getName() != null && !SecurityContextHolder.getContext().getAuthentication().getName().equals("anonymousUser")) { currentUserId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); - // --- 디버깅 로그 추가 시작 --- - System.out.println("DEBUG: Authenticated user ID: " + currentUserId); - // --- 디버깅 로그 추가 끝 --- - } else { - // --- 디버깅 로그 추가 시작 --- - System.out.println("DEBUG: User is not authenticated or is anonymous."); - // --- 디버깅 로그 추가 끝 --- } } catch (NumberFormatException e) { - System.out.println("DEBUG: Error parsing user ID from SecurityContext: " + e.getMessage()); currentUserId = null; } @@ -326,6 +310,7 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { .category(vote.getCategory()) .totalVoteCount(vote.getTotalVoteCount()) .creatorNickname(creatorNickname) // 수정된 닉네임 사용 + .creatorTitle(creatorTitle) .createdAt(vote.getCreatedAt()) .options(optionDtos) .hasVoted(hasVoted) // 새로운 필드 값 설정 @@ -386,11 +371,16 @@ public Long createVote(Long userId, VoteCreateRequest request) { commentGroupRepository.save(commentGroup); // CommentGroup 저장 + // 게시물 작성 포인트 지급 + pointService.givePoint(userId, PointType.POST_CREATE); + return savedVote.getId(); // 저장된 투표의 ID를 반환 } @Override public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String category, String sort, String cursor, int size) { + validateVoteListRequest(category, sort, cursor, size); + List<Vote> votes = voteRepository.findVotesByCursor(category, sort, cursor, size); boolean hasNext = votes.size() > size; @@ -400,9 +390,9 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ votes.remove(votes.size() - 1); Vote lastVote = votes.get(votes.size() - 1); if ("popular".equalsIgnoreCase(sort)) { - nextCursor = lastVote.getTotalVoteCount() + "_" + lastVote.getCreatedAt().toString(); + nextCursor = lastVote.getTotalVoteCount() + "_" + lastVote.getCreatedAt() + "_" + lastVote.getId(); } else { // latest - nextCursor = lastVote.getCreatedAt().toString(); + nextCursor = lastVote.getCreatedAt() + "_" + lastVote.getId(); } } @@ -439,6 +429,7 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ .category(vote.getCategory().name()) .member_id(vote.getMember() != null ? vote.getMember().getId() : null) .nickname(creatorNickname) + .member_title(getEquippedTitleName(vote.getMember())) .created_at(vote.getCreatedAt()) .total_vote_count(vote.getTotalVoteCount()) .total_comment_count(totalCommentCount) @@ -455,6 +446,69 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ .build(); } + private void validateVoteListRequest(String category, String sort, String cursor, int size) { + if (size < 1) { + throw new ApiException("size는 1 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + + validateCategory(category); + validateSort(sort); + validateCursor(sort, cursor); + } + + private void validateCategory(String category) { + if (category == null || category.isBlank()) { + throw new ApiException("category는 ALL, FOOD, LOVE, ETC 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + + try { + VoteCategory.valueOf(category.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ApiException("category는 ALL, FOOD, LOVE, ETC 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private void validateSort(String sort) { + if (sort == null || sort.isBlank()) { + throw new ApiException("sort는 latest 또는 popular 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + + if (!"latest".equalsIgnoreCase(sort) && !"popular".equalsIgnoreCase(sort)) { + throw new ApiException("sort는 latest 또는 popular 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private void validateCursor(String sort, String cursor) { + if (cursor == null) { + return; + } + + if (cursor.isBlank()) { + throw new ApiException("cursor 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST); + } + + try { + if ("popular".equalsIgnoreCase(sort)) { + String[] parts = cursor.split("_"); + if (parts.length != 3) { + throw new IllegalArgumentException(); + } + Integer.parseInt(parts[0]); + LocalDateTime.parse(parts[1]); + Long.parseLong(parts[2]); + } else { + String[] parts = cursor.split("_"); + if (parts.length != 2) { + throw new IllegalArgumentException(); + } + LocalDateTime.parse(parts[0]); + Long.parseLong(parts[1]); + } + } catch (RuntimeException e) { + throw new ApiException("cursor 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST); + } + } + @Override @Transactional public void deleteVote(Long userId, Long voteId) { @@ -530,10 +584,22 @@ private HotIssueVoteResponse getHotIssueVoteResponse(Vote hotIssueVote) { .category(hotIssueVote.getCategory() != null ? hotIssueVote.getCategory().name() : null) // 카테고리 설정 .totalParticipants(hotIssueVote.getTotalVoteCount()) // 총 참여자 수 설정 .createdBy(createdByNickname) // 생성자 닉네임 설정 + .creatorTitle(getEquippedTitleName(hotIssueVote.getMember())) .createdAt(hotIssueVote.getCreatedAt()) // 추가된 부분: createdAt 설정 .pinType(hotIssueVote.getPinType()) .options(options) // 옵션 리스트 설정 .build(); } + private String getEquippedTitleName(Member member) { + if (member == null || member.getId() == null) { + return null; + } + + return memberProfileTitleRepository.findByMemberProfileMemberIdAndEquippedTrue(member.getId()) + .map(MemberProfileTitle::getTitle) + .map(Title::getName) + .orElse(null); + } + } diff --git a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java index 9b96222..30bf1fd 100644 --- a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java +++ b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java @@ -5,9 +5,13 @@ import com.valanse.valanse.domain.*; import com.valanse.valanse.domain.enums.*; import com.valanse.valanse.repository.CommentGroupRepository; -import com.valanse.valanse.repository.MemberProfileRepository; // -import com.valanse.valanse.repository.MemberRepository; // +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.VoteRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -21,241 +25,228 @@ import java.time.LocalDateTime; import java.util.Arrays; -import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest // Spring Boot 테스트 환경 로드 -@AutoConfigureMockMvc // MockMvc 자동 구성 -@ActiveProfiles("test") // application-test.yml 프로파일 활성화 -@Transactional // 각 테스트 메서드가 끝날 때 트랜잭션 롤백 +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional public class VoteControllerTest { - @Autowired - private MockMvc mockMvc; // HTTP 요청을 시뮬레이션하는 데 사용 + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private VoteRepository voteRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private MemberProfileRepository memberProfileRepository; + @Autowired private CommentGroupRepository commentGroupRepository; + @Autowired private EntityManager entityManager; + @Autowired private EntityManagerFactory entityManagerFactory; - @Autowired - private ObjectMapper objectMapper; // JSON 직렬화/역직렬화를 위한 유틸리티 - - @Autowired - private VoteRepository voteRepository; - - @Autowired - private MemberRepository memberRepository; // Member 저장을 위해 필요 - - @Autowired - private MemberProfileRepository memberProfileRepository; // MemberProfile 저장을 위해 필요 - - @Autowired - private CommentGroupRepository commentGroupRepository; - - @BeforeEach // 각 테스트 메서드 실행 전에 실행 + @BeforeEach void setUp() { - // 데이터 클린업 (Transactional 어노테이션으로 롤백되므로 필수는 아니지만 명시적으로 초기화) voteRepository.deleteAll(); commentGroupRepository.deleteAll(); memberProfileRepository.deleteAll(); memberRepository.deleteAll(); - // 핫이슈 투표 생성 시 필요한 멤버 및 프로필 데이터 생성 - Member member1 = Member.builder() // - .socialId("kakao123") - .email("test1@example.com") - .name("테스터1") + Member member1 = Member.builder() + .socialId("kakao123").email("test1@example.com").name("테스터1") .profile_image_url("http://image.com/test1.jpg") - .kakaoAccessToken("token1") - .kakaoRefreshToken("refresh1") - .build(); - memberRepository.save(member1); // - - MemberProfile profile1 = MemberProfile.builder() // - .member(member1) - .nickname("테스터1닉네임") - .gender(Gender.MALE) // - .age(Age.TWENTY) // - .mbti("ENFP") - .build(); - memberProfileRepository.save(profile1); // - - // 투표 생성 - 핫이슈 (가장 높은 반응성) - Vote hotIssueVote = Vote.builder() // - .category(VoteCategory.FOOD) // <-- 88번 줄: VoteCategory enum 값을 올바르게 할당 - .title("오늘의 점심 선택은?") - .totalVoteCount(100) - .reactivityScore(110) // 투표 100 + 댓글 10 = 반응성 110 - .reactivityUpdatedAt(LocalDateTime.now()) // 현재 시간으로 설정 - .member(member1)// - .pinType(PinType.NONE) - .build(); + .kakaoAccessToken("token1").kakaoRefreshToken("refresh1").build(); + memberRepository.save(member1); + + memberProfileRepository.save(MemberProfile.builder() + .member(member1).nickname("테스터1닉네임") + .gender(Gender.MALE).age(Age.TWENTY).mbti("ENFP").build()); + + Vote hotIssueVote = Vote.builder() + .category(VoteCategory.FOOD).title("오늘의 점심 선택은?") + .totalVoteCount(100).reactivityScore(110) + .reactivityUpdatedAt(LocalDateTime.now()) + .member(member1).pinType(PinType.NONE).build(); voteRepository.save(hotIssueVote); - // CommentGroup 생성 (댓글 10개) - CommentGroup commentGroup = CommentGroup.builder() - .vote(hotIssueVote) - .totalCommentCount(10) - .build(); - commentGroupRepository.save(commentGroup); - - - // 핫이슈 투표 옵션들 - VoteOption optionA = VoteOption.builder() // - .vote(hotIssueVote) - .content("A. 맵고 얼큰한 라면") - .voteCount(60) - .label(VoteLabel.A) // - .build(); - VoteOption optionB = VoteOption.builder() // - .vote(hotIssueVote) - .content("B. 부드러운 파스타") - .voteCount(40) - .label(VoteLabel.B) // - .build(); + commentGroupRepository.save(CommentGroup.builder() + .vote(hotIssueVote).totalCommentCount(10).build()); + + VoteOption optionA = VoteOption.builder().vote(hotIssueVote) + .content("A. 맵고 얼큰한 라면").voteCount(60).label(VoteLabel.A).build(); + VoteOption optionB = VoteOption.builder().vote(hotIssueVote) + .content("B. 부드러운 파스타").voteCount(40).label(VoteLabel.B).build(); hotIssueVote.getVoteOptions().addAll(Arrays.asList(optionA, optionB)); - voteRepository.save(hotIssueVote); // 옵션 추가 후 다시 저장 + voteRepository.save(hotIssueVote); - // 다른 투표 생성 (반응성이 더 낮은 투표) - Member member2 = Member.builder() // - .socialId("kakao456") - .email("test2@example.com") - .name("테스터2") + Member member2 = Member.builder() + .socialId("kakao456").email("test2@example.com").name("테스터2") .profile_image_url("http://image.com/test2.jpg") - .kakaoAccessToken("token2") - .kakaoRefreshToken("refresh2") - .build(); - memberRepository.save(member2); // - - MemberProfile profile2 = MemberProfile.builder() // - .member(member2) - .nickname("테스터2닉네임") - .gender(Gender.FEMALE) // - .age(Age.THIRTY) // - .mbti("ISTJ") - .build(); - memberProfileRepository.save(profile2); // - - // 다른 투표 생성 (반응성이 더 낮은 투표) - Vote otherVote = Vote.builder() // - .category(VoteCategory.LOVE) // <-- 133번 줄: VoteCategory enum 값을 올바르게 할당 - .title("연애 밸런스 게임") - .totalVoteCount(50) - .reactivityScore(55) // 투표 50 + 댓글 5 = 반응성 55 + .kakaoAccessToken("token2").kakaoRefreshToken("refresh2").build(); + memberRepository.save(member2); + + memberProfileRepository.save(MemberProfile.builder() + .member(member2).nickname("테스터2닉네임") + .gender(Gender.FEMALE).age(Age.THIRTY).mbti("ISTJ").build()); + + Vote otherVote = Vote.builder() + .category(VoteCategory.LOVE).title("연애 밸런스 게임") + .totalVoteCount(50).reactivityScore(55) .reactivityUpdatedAt(LocalDateTime.now()) - .member(member2) - .pinType(PinType.NONE)// - .build(); + .member(member2).pinType(PinType.NONE).build(); voteRepository.save(otherVote); - CommentGroup commentGroup2 = CommentGroup.builder() - .vote(otherVote) - .totalCommentCount(5) - .build(); - commentGroupRepository.save(commentGroup2); + commentGroupRepository.save(CommentGroup.builder() + .vote(otherVote).totalCommentCount(5).build()); } @Test @DisplayName("반응성이 가장 높은 핫이슈 투표 정보를 성공적으로 조회한다.") void getHotIssueVote_Success() throws Exception { - mockMvc.perform(get("/votes/best") // GET 요청 - .contentType(MediaType.APPLICATION_JSON)) // 요청 타입 - .andExpect(status().isOk()) // HTTP 상태 코드 200 OK 확인 - .andExpect(jsonPath("$.voteId").isNumber()) // voteId가 숫자인지 확인 - .andExpect(jsonPath("$.title").value("오늘의 점심 선택은?")) // 제목 확인 반응성 1위 - .andExpect(jsonPath("$.category").value(VoteCategory.FOOD.name())) // 카테고리 확인 - .andExpect(jsonPath("$.totalParticipants").value(100)) // 총 투표수 확인 - .andExpect(jsonPath("$.createdBy").value("테스터1닉네임")) // 생성자 닉네임 확인 - .andExpect(jsonPath("$.options[0].content").value("A. 맵고 얼큰한 라면")) // 첫 번째 옵션 내용 확인 - .andExpect(jsonPath("$.options[0].vote_count").value(60)) // 첫 번째 옵션 투표 수 확인 - .andExpect(jsonPath("$.options[1].content").value("B. 부드러운 파스타")) // 두 번째 옵션 내용 확인 - .andExpect(jsonPath("$.options[1].vote_count").value(40)); // 두 번째 옵션 투표 수 확인 + mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voteId").isNumber()) + .andExpect(jsonPath("$.title").value("오늘의 점심 선택은?")) + .andExpect(jsonPath("$.category").value(VoteCategory.FOOD.name())) + .andExpect(jsonPath("$.totalParticipants").value(100)) + .andExpect(jsonPath("$.createdBy").value("테스터1닉네임")) + .andExpect(jsonPath("$.options[0].content").value("A. 맵고 얼큰한 라면")) + .andExpect(jsonPath("$.options[0].vote_count").value(60)) + .andExpect(jsonPath("$.options[1].content").value("B. 부드러운 파스타")) + .andExpect(jsonPath("$.options[1].vote_count").value(40)); } @Test @DisplayName("핫이슈 투표가 없을 때 404 Not Found를 반환한다.") void getHotIssueVote_NotFound() throws Exception { - // 모든 투표 삭제하여 핫이슈 투표가 없는 상태로 만듦 commentGroupRepository.deleteAll(); voteRepository.deleteAll(); - mockMvc.perform(get("/votes/best") // GET 요청 - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()) // HTTP 상태 코드 404 Not Found 확인 - .andExpect(jsonPath("$.error").value("핫이슈 투표를 찾을 수 없습니다.")) // 에러 메시지 확인 - .andExpect(jsonPath("$.status").value(404)) // 상태 코드 확인 - .andExpect(jsonPath("$.type").value("ApiException")); // 예외 타입 확인 + mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("핫이슈 투표를 찾을 수 없습니다.")) + .andExpect(jsonPath("$.status").value(404)); + // 참고: GlobalExceptionHandler에서 "type" 필드는 주석 처리되어 있음 } @Test @DisplayName("동일한 반응성을 가진 투표 중 최신 투표를 조회한다.") void getHotIssueVote_SameTotalVoteCount_NewerIsHotIssue() throws Exception { - // 데이터 클린업 commentGroupRepository.deleteAll(); voteRepository.deleteAll(); - // 멤버 생성 Member member3 = Member.builder() - .socialId("kakao789") - .email("test3@example.com") - .name("테스터3") + .socialId("kakao789").email("test3@example.com").name("테스터3") .profile_image_url("http://image.com/test3.jpg") - .kakaoAccessToken("token3") - .kakaoRefreshToken("refresh3") - .build(); + .kakaoAccessToken("token3").kakaoRefreshToken("refresh3").build(); memberRepository.save(member3); - MemberProfile profile3 = MemberProfile.builder() - .member(member3) - .nickname("테스터3닉네임") - .gender(Gender.MALE) - .age(Age.OVER_FORTY) - .mbti("INTP") - .build(); - memberProfileRepository.save(profile3); + memberProfileRepository.save(MemberProfile.builder() + .member(member3).nickname("테스터3닉네임") + .gender(Gender.MALE).age(Age.OVER_FORTY).mbti("INTP").build()); - // 오래된 투표 (반응성: 50) Vote oldVote = Vote.builder() - .category(VoteCategory.ETC) - .title("오래된 핫이슈 투표") - .totalVoteCount(50) - .reactivityScore(50) // 추가 - .reactivityUpdatedAt(LocalDateTime.now().minusDays(3)) // 3일 전 - .member(member3) - .pinType(PinType.NONE) - .build(); + .category(VoteCategory.ETC).title("오래된 핫이슈 투표") + .totalVoteCount(50).reactivityScore(50) + .reactivityUpdatedAt(LocalDateTime.now().minusDays(3)) + .member(member3).pinType(PinType.NONE).build(); voteRepository.save(oldVote); + commentGroupRepository.save(CommentGroup.builder().vote(oldVote).totalCommentCount(0).build()); - // CommentGroup 생성 - CommentGroup commentGroup3 = CommentGroup.builder() - .vote(oldVote) - .totalCommentCount(0) - .build(); - commentGroupRepository.save(commentGroup3); - - // 새로운 투표 (동일한 반응성: 50, 하지만 더 최신) Vote newVote = Vote.builder() - .category(VoteCategory.LOVE) - .title("새로운 핫이슈 투표") - .totalVoteCount(50) - .reactivityScore(50) // 추가 - .reactivityUpdatedAt(LocalDateTime.now()) // 현재 시간 - .member(member3) - .pinType(PinType.NONE) - .build(); + .category(VoteCategory.LOVE).title("새로운 핫이슈 투표") + .totalVoteCount(50).reactivityScore(50) + .reactivityUpdatedAt(LocalDateTime.now()) + .member(member3).pinType(PinType.NONE).build(); voteRepository.save(newVote); + commentGroupRepository.save(CommentGroup.builder().vote(newVote).totalCommentCount(0).build()); - // CommentGroup 생성 - CommentGroup commentGroup4 = CommentGroup.builder() - .vote(newVote) - .totalCommentCount(0) - .build(); - commentGroupRepository.save(commentGroup4); - - mockMvc.perform(get("/votes/best") - .contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("새로운 핫이슈 투표")) // 최신 투표가 선택 + .andExpect(jsonPath("$.title").value("새로운 핫이슈 투표")) .andExpect(jsonPath("$.totalParticipants").value(50)) .andExpect(jsonPath("$.createdBy").value("테스터3닉네임")); } -} \ No newline at end of file + + @Test + @DisplayName("투표 목록 조회 시 목록 데이터와 연관 데이터를 고정된 쿼리 수로 조회한다.") + void getVotes_QueryCountIsStable() throws Exception { + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + entityManager.flush(); + entityManager.clear(); + statistics.clear(); + + mockMvc.perform(get("/votes") + .param("category", "ALL") + .param("sort", "latest") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.votes.length()").value(2)) + .andExpect(jsonPath("$.votes[0].nickname").value("테스터2닉네임")) + .andExpect(jsonPath("$.votes[1].nickname").value("테스터1닉네임")) + .andExpect(jsonPath("$.votes[1].options.length()").value(2)) + .andExpect(jsonPath("$.has_next_page").value(false)); + + assertThat(statistics.getPrepareStatementCount()).isEqualTo(2); + } + + @Test + @DisplayName("투표 목록 조회 시 size가 1보다 작으면 400 Bad Request를 반환한다.") + void getVotes_InvalidSize_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("size", "0") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size는 1 이상이어야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 잘못된 category는 400 Bad Request를 반환한다.") + void getVotes_InvalidCategory_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("category", "INVALID") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("category는 ALL, FOOD, LOVE, ETC 중 하나여야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 잘못된 sort는 400 Bad Request를 반환한다.") + void getVotes_InvalidSort_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("sort", "oldest") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("sort는 latest 또는 popular 중 하나여야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 latest cursor 형식이 잘못되면 400 Bad Request를 반환한다.") + void getVotes_InvalidLatestCursor_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("sort", "latest") + .param("cursor", "2026-05-18T13:00:00") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("cursor 형식이 올바르지 않습니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 popular cursor 형식이 잘못되면 400 Bad Request를 반환한다.") + void getVotes_InvalidPopularCursor_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("sort", "popular") + .param("cursor", "100_2026-05-18T13:00:00") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("cursor 형식이 올바르지 않습니다.")) + .andExpect(jsonPath("$.status").value(400)); + } +} diff --git a/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java b/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java index 49e43ed..93c147b 100644 --- a/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java @@ -1,5 +1,6 @@ package com.valanse.valanse.service.CommentLikeService; +import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Comment; import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.mapping.CommentLike; @@ -8,6 +9,7 @@ import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.MemberRepository; 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; @@ -20,91 +22,82 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class CommentLikeServiceImplTest { + @InjectMocks private CommentLikeServiceImpl commentLikeService; - @Mock - private CommentRepository commentRepository; - @Mock - private MemberRepository memberRepository; - @Mock - private CommentLikeRepository commentLikeRepository; + @Mock private CommentRepository commentRepository; + @Mock private MemberRepository memberRepository; + @Mock private CommentLikeRepository commentLikeRepository; - // Security에서 userId를 뽑아오기 때문에 미리 설정 @BeforeEach void setupSecurityContext() { - Authentication authentication = mock(Authentication.class); - when(authentication.getName()).thenReturn("1"); // userId=1 가정 - SecurityContext securityContext = mock(SecurityContext.class); - when(securityContext.getAuthentication()).thenReturn(authentication); - SecurityContextHolder.setContext(securityContext); + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn("1"); + SecurityContext ctx = mock(SecurityContext.class); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); } - @Test - public void 좋아요_test() { - //given + @DisplayName("좋아요가 없는 댓글에 좋아요를 누르면 likeCount가 1 증가한다") + void 좋아요_성공() { Member member = new Member(); + Comment comment = Comment.builder().content("댓글").likeCount(0).build(); + CommentLike commentLike = CommentLike.builder().comment(comment).user(member).build(); - Comment comment = Comment.builder() - .content("좋아요 없는 댓글") - .likeCount(0) - .build(); - - CommentLike commentLike = CommentLike.builder() - .comment(comment) - .user(member) - .build(); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); - // 첫번째 호출에서는 빈값, 두번째 호출에서는 commentLike 반환. when(commentLikeRepository.findByUserIdAndCommentId(any(), any())) .thenReturn(Optional.empty()) .thenReturn(Optional.of(commentLike)); - // when - CommentLikeResponseDto responseDto = commentLikeService.likeComment(1L, 1L); + CommentLikeResponseDto result = commentLikeService.likeComment(1L, 1L); - //then: 응답 dto 반환 값 확인 - assertThat(responseDto.getLikeCount()).isEqualTo(1); - assertThat(responseDto.getMessage()).isEqualTo("좋아요 성공"); + assertThat(result.getLikeCount()).isEqualTo(1); + assertThat(result.getMessage()).isEqualTo("좋아요 성공"); } @Test - void 좋아요_취소_test() { - // when + @DisplayName("이미 좋아요를 누른 댓글에 다시 누르면 취소되고 likeCount가 감소한다") + void 좋아요_취소() { Member member = new Member(); + Comment comment = Comment.builder().content("댓글").likeCount(1).build(); + CommentLike commentLike = CommentLike.builder().comment(comment).user(member).build(); - Comment comment = Comment.builder() - .content("좋아요 누른 댓글") - .likeCount(1) - .build(); - - CommentLike commentLike = CommentLike.builder() - .comment(comment) - .user(member) - .build(); - - // stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); when(commentLikeRepository.findByUserIdAndCommentId(any(), any())) .thenReturn(Optional.of(commentLike)) .thenReturn(Optional.empty()); - //when - CommentLikeResponseDto responseDto = commentLikeService.likeComment(1L, 1L); + CommentLikeResponseDto result = commentLikeService.likeComment(1L, 1L); + + assertThat(result.getLikeCount()).isEqualTo(0); + assertThat(result.getMessage()).isEqualTo("좋아요 취소"); + } + + @Test + @DisplayName("존재하지 않는 회원이 좋아요를 시도하면 예외가 발생한다") + void 좋아요_존재하지않는회원() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.empty()); + + assertThrows(Exception.class, () -> commentLikeService.likeComment(1L, 1L)); + verify(commentRepository, never()).findById(any()); + } + + @Test + @DisplayName("존재하지 않는 댓글에 좋아요를 시도하면 예외가 발생한다") + void 좋아요_존재하지않는댓글() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); + when(commentRepository.findById(any())).thenReturn(Optional.empty()); - //then: 응답 dto 반환 값 확인 - assertThat(responseDto.getLikeCount()).isEqualTo(0); - assertThat(responseDto.getMessage()).isEqualTo("좋아요 취소"); + assertThrows(Exception.class, () -> commentLikeService.likeComment(1L, 999L)); } - -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java index 4fbeddc..41913f2 100644 --- a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java @@ -5,6 +5,7 @@ import com.valanse.valanse.dto.Comment.BestCommentResponseDto; import com.valanse.valanse.dto.Comment.CommentPostRequest; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,6 +36,10 @@ class CommentServiceImplTest { private CommentGroupRepository commentGroupRepository; @Mock private MemberProfileRepository memberProfileRepository; + @Mock + private MemberProfileTitleRepository memberProfileTitleRepository; + @Mock + private PointService pointService; private Member member; private Vote vote; @@ -432,4 +437,4 @@ void getBestComment_actualCount_test() { assertThat(response.totalCommentCount()).isEqualTo(7); assertThat(response.content()).isEqualTo("인기 댓글"); } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java index ec54e7d..59a709c 100644 --- a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java @@ -2,10 +2,15 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; import com.valanse.valanse.domain.enums.*; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.TitleRepository; +import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,19 +29,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -/** - * Issue #110: MBTI 프로필 수정 검증 로직 테스트 - * - * 테스트 시나리오: - * 1. 닉네임 변경 없이 MBTI만 수정 - 중복 체크 없이 정상 저장 - * 2. MBTI 2~3글자만 입력 - "MBTI는 4자리여야 합니다" 에러 - * 3. IE만 선택하고 TF 미선택 - "MBTI를 모두 선택해주세요" 에러 - * 4. 닉네임 중복 (실제 중복) - "이미 사용 중인 닉네임입니다" 에러 - * 5. 신규 프로필 생성 시 닉네임 중복 - "이미 사용 중인 닉네임입니다" 에러 - * 6. 정상적인 MBTI 4글자 입력 - 정상 저장 - */ @ExtendWith(MockitoExtension.class) -class MemberProfileServiceImplTest_Issue110 { +class MemberProfileServiceImplTest { @InjectMocks private MemberProfileServiceImpl memberProfileService; @@ -47,20 +41,26 @@ class MemberProfileServiceImplTest_Issue110 { @Mock private MemberRepository memberRepository; + @Mock + private MemberProfileTitleRepository memberProfileTitleRepository; + + @Mock + private TitleRepository titleRepository; + + @Mock + private PointService pointService; + private Member member; @BeforeEach void setupSecurityContext() { - // SecurityContext 설정 (userId = 1) Authentication authentication = mock(Authentication.class); - when(authentication.getName()).thenReturn("1"); + lenient().when(authentication.getName()).thenReturn("1"); SecurityContext securityContext = mock(SecurityContext.class); - when(securityContext.getAuthentication()).thenReturn(authentication); + lenient().when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); - // Member 객체 초기화 member = Member.builder() - .id(1L) .email("test@email.com") .nickname("테스터") .name("test") @@ -68,164 +68,104 @@ void setupSecurityContext() { .build(); } + // ─────────────────────────────────────────────── + // [핵심] soft delete 회원 닉네임 재사용 시나리오 + // ─────────────────────────────────────────────── + @Test - @DisplayName("테스트 1: 닉네임 변경 없이 MBTI만 수정 - 중복 체크 없이 정상 저장") - void 닉네임_변경없이_MBTI만_수정_성공() { + @DisplayName("[핵심] 탈퇴한 회원의 닉네임은 신규 가입자가 사용할 수 있어야 한다") + void 탈퇴회원_닉네임_신규회원이_재사용_가능() { // given - MemberProfile existingProfile = MemberProfile.builder() - .member(member) - .nickname("기존닉네임") - .gender(Gender.MALE) - .age(Age.TWENTY) - .mbtiIe(MbtiIe.E) - .mbtiTf(MbtiTf.T) - .mbti("ENTP") - .build(); - MemberProfileRequest request = new MemberProfileRequest( - "기존닉네임", // 닉네임 변경 없음 + "탈퇴한닉네임", // soft delete된 회원이 쓰던 닉네임 Gender.MALE, Age.TWENTY, - MbtiIe.I, // IE 변경: E -> I - MbtiTf.F, // TF 변경: T -> F - "INFP" // MBTI 변경 + MbtiIe.E, + MbtiTf.T, + "ENTP" ); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.of(existingProfile)); - - // when - memberProfileService.saveOrUpdateProfile(request); + .thenReturn(Optional.empty()); // 신규 가입자 (프로필 없음) + // soft delete된 회원의 닉네임은 deletedAt IS NULL 조건에서 걸리지 않음 + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("탈퇴한닉네임")) + .thenReturn(false); - // then - // 닉네임이 변경되지 않았으므로 중복 체크를 호출하지 않아야 함 - verify(memberProfileRepository, never()).existsByNickname(any()); - - // save는 1회 호출 + // when & then: 예외 없이 정상 저장 + assertDoesNotThrow(() -> memberProfileService.saveOrUpdateProfile(request)); verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); - - // 프로필 업데이트 확인 - assertThat(existingProfile.getMbti()).isEqualTo("INFP"); - assertThat(existingProfile.getMbtiIe()).isEqualTo(MbtiIe.I); - assertThat(existingProfile.getMbtiTf()).isEqualTo(MbtiTf.F); } @Test - @DisplayName("테스트 2: MBTI 2~3글자만 입력 - 'MBTI는 4자리여야 합니다' 에러") - void MBTI_불완전_입력_실패() { - // given + @DisplayName("신규 프로필 생성 시 기본 칭호를 지급하고 장착한다") + void 신규프로필_기본칭호_지급_및_장착() { MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", + "새싹유저", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, - "ENT" // 3글자만 입력 + "ENTP" ); + Title defaultTitle = Title.builder() + .id(1L) + .code("DEFAULT_SEED") + .name("밸런스 새싹") + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.DEFAULT) + .build(); - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.empty()); + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("새싹유저")).thenReturn(false); + when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(titleRepository.findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT)) + .thenReturn(Optional.of(defaultTitle)); - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); - - assertThat(exception.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); + memberProfileService.saveOrUpdateProfile(request); - // 저장이 호출되지 않아야 함 - verify(memberProfileRepository, never()).save(any()); + verify(memberProfileTitleRepository).save(argThat(profileTitle -> + profileTitle.getTitle().equals(defaultTitle) + && profileTitle.isEquipped() + && profileTitle.getMemberProfile().getNickname().equals("새싹유저") + )); } @Test - @DisplayName("테스트 2-1: MBTI null 입력 - 'MBTI는 4자리여야 합니다' 에러") - void MBTI_null_입력_실패() { + @DisplayName("[핵심] 활성 회원이 사용 중인 닉네임은 중복으로 막혀야 한다") + void 활성회원_닉네임_중복_차단() { // given MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", + "활성회원닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, - null // null 입력 - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); - - assertThat(exception.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); - } - - @Test - @DisplayName("테스트 3: IE만 선택하고 TF 미선택 - 'MBTI를 모두 선택해주세요' 에러") - void MBTI_일부만_선택_실패() { - // given - MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", - Gender.MALE, - Age.TWENTY, - MbtiIe.E, // IE만 선택 - null, // TF 미선택 "ENTP" ); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.empty()); + // 활성 회원이 이미 사용 중 + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("활성회원닉네임")) + .thenReturn(true); // when & then IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> memberProfileService.saveOrUpdateProfile(request) ); - - assertThat(exception.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); - - // 저장이 호출되지 않아야 함 + assertThat(exception.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); verify(memberProfileRepository, never()).save(any()); } @Test - @DisplayName("테스트 3-1: TF만 선택하고 IE 미선택 - 'MBTI를 모두 선택해주세요' 에러") - void MBTI_IE만_미선택_실패() { - // given - MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", - Gender.MALE, - Age.TWENTY, - null, // IE 미선택 - MbtiTf.T, // TF만 선택 - "ENTP" - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); - - assertThat(exception.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); - } - - @Test - @DisplayName("테스트 4: 닉네임 변경 시 실제 중복 - '이미 사용 중인 닉네임입니다' 에러") - void 닉네임_변경시_중복_에러() { + @DisplayName("[핵심] 프로필 수정 시 탈퇴한 회원의 닉네임으로 변경 가능해야 한다") + void 프로필수정시_탈퇴회원_닉네임으로_변경_가능() { // given MemberProfile existingProfile = MemberProfile.builder() .member(member) @@ -238,7 +178,7 @@ void setupSecurityContext() { .build(); MemberProfileRequest request = new MemberProfileRequest( - "중복닉네임", // 다른 사람이 이미 사용 중 + "탈퇴한닉네임", // 탈퇴 회원이 쓰던 닉네임으로 변경 시도 Gender.MALE, Age.TWENTY, MbtiIe.E, @@ -246,194 +186,239 @@ void setupSecurityContext() { "ENTP" ); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); when(memberProfileRepository.findByMemberId(1L)) .thenReturn(Optional.of(existingProfile)); - when(memberProfileRepository.existsByNickname("중복닉네임")) - .thenReturn(true); // 중복됨 + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("탈퇴한닉네임")) + .thenReturn(false); // 탈퇴 회원 닉네임이므로 null 아닌 deletedAt 갖고 있음 // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) + assertDoesNotThrow(() -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(existingProfile.getNickname()).isEqualTo("탈퇴한닉네임"); + verify(memberProfileRepository, times(1)).save(existingProfile); + } + + @Test + @DisplayName("[핵심] isAvailableNickname은 soft delete된 회원 닉네임을 사용 가능으로 반환해야 한다") + void isAvailableNickname_탈퇴회원_닉네임은_사용가능() { + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("탈퇴한닉네임")) + .thenReturn(false); + + assertThat(memberProfileService.isAvailableNickname("탈퇴한닉네임")).isTrue(); + } + + @Test + @DisplayName("[핵심] isAvailableNickname은 활성 회원 닉네임을 사용 불가로 반환해야 한다") + void isAvailableNickname_활성회원_닉네임은_사용불가() { + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("활성닉네임")) + .thenReturn(true); + + assertThat(memberProfileService.isAvailableNickname("활성닉네임")).isFalse(); + } + + // ─────────────────────────────────────────────── + // 기존 시나리오 + // ─────────────────────────────────────────────── + + @Test + @DisplayName("닉네임 변경 없이 MBTI만 수정 - 중복 체크 없이 정상 저장") + void 닉네임_변경없이_MBTI만_수정_성공() { + MemberProfile existingProfile = MemberProfile.builder() + .member(member).nickname("기존닉네임") + .gender(Gender.MALE).age(Age.TWENTY) + .mbtiIe(MbtiIe.E).mbtiTf(MbtiTf.T).mbti("ENTP") + .build(); + + MemberProfileRequest request = new MemberProfileRequest( + "기존닉네임", Gender.MALE, Age.TWENTY, MbtiIe.I, MbtiTf.F, "INFP" ); - assertThat(exception.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(existingProfile)); - // 중복 체크는 호출되어야 함 - verify(memberProfileRepository, times(1)).existsByNickname("중복닉네임"); + memberProfileService.saveOrUpdateProfile(request); - // 저장은 호출되지 않아야 함 - verify(memberProfileRepository, never()).save(any()); + verify(memberProfileRepository, never()).existsByNicknameAndDeletedAtIsNull(any()); + verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); + assertThat(existingProfile.getMbti()).isEqualTo("INFP"); } @Test - @DisplayName("테스트 5: 신규 프로필 생성 시 닉네임 중복 - '이미 사용 중인 닉네임입니다' 에러") - void 신규_프로필_닉네임_중복_에러() { - // given + @DisplayName("MBTI 3글자 입력 - 'MBTI는 4자리여야 합니다' 에러") + void MBTI_불완전_입력_실패() { MemberProfileRequest request = new MemberProfileRequest( - "중복닉네임", - Gender.MALE, - Age.TWENTY, - MbtiIe.E, - MbtiTf.T, - "ENTP" + "테스트닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, "ENT" ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.empty()); // 기존 프로필 없음 (신규) - when(memberProfileRepository.existsByNickname("중복닉네임")) - .thenReturn(true); // 중복됨 + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); + verify(memberProfileRepository, never()).save(any()); + } - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) + @Test + @DisplayName("MBTI null 입력 - 'MBTI는 4자리여야 합니다' 에러") + void MBTI_null_입력_실패() { + MemberProfileRequest request = new MemberProfileRequest( + "테스트닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, null ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - assertThat(exception.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); + } - // 신규 생성 시에도 중복 체크 호출 - verify(memberProfileRepository, times(1)).existsByNickname("중복닉네임"); + @Test + @DisplayName("IE만 선택하고 TF 미선택 - 'MBTI를 모두 선택해주세요' 에러") + void MBTI_TF_미선택_실패() { + MemberProfileRequest request = new MemberProfileRequest( + "테스트닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, null, "ENTP" + ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - // 저장은 호출되지 않아야 함 + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); verify(memberProfileRepository, never()).save(any()); } @Test - @DisplayName("테스트 6: 정상적인 MBTI 4글자 입력 - 정상 저장") - void 정상_프로필_저장_성공() { - // given + @DisplayName("TF만 선택하고 IE 미선택 - 'MBTI를 모두 선택해주세요' 에러") + void MBTI_IE_미선택_실패() { MemberProfileRequest request = new MemberProfileRequest( - "새로운닉네임", - Gender.FEMALE, - Age.THIRTY, - MbtiIe.I, - MbtiTf.F, - "INFP" // 정상적인 4글자 + "테스트닉네임", Gender.MALE, Age.TWENTY, null, MbtiTf.T, "ENTP" ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - MemberProfile newProfile = MemberProfile.builder() - .member(member) - .nickname(request.nickname()) - .gender(request.gender()) - .age(request.age()) - .mbtiIe(request.mbtiIe()) - .mbtiTf(request.mbtiTf()) - .mbti(request.mbti()) + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); + } + + @Test + @DisplayName("닉네임 변경 시 활성 회원과 중복 - '이미 사용 중인 닉네임입니다' 에러") + void 닉네임_변경시_활성회원_중복_에러() { + MemberProfile existingProfile = MemberProfile.builder() + .member(member).nickname("기존닉네임") + .gender(Gender.MALE).age(Age.TWENTY) + .mbtiIe(MbtiIe.E).mbtiTf(MbtiTf.T).mbti("ENTP") .build(); - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.empty()); // 신규 생성 - when(memberProfileRepository.existsByNickname("새로운닉네임")) - .thenReturn(false); // 중복 없음 - when(memberProfileRepository.save(any(MemberProfile.class))) - .thenReturn(newProfile); + MemberProfileRequest request = new MemberProfileRequest( + "중복닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, "ENTP" + ); - // when - memberProfileService.saveOrUpdateProfile(request); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(existingProfile)); + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("중복닉네임")).thenReturn(true); - // then - // 중복 체크 호출 - verify(memberProfileRepository, times(1)).existsByNickname("새로운닉네임"); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + verify(memberProfileRepository, never()).save(any()); + } - // 저장 1회 호출 + @Test + @DisplayName("닉네임 변경 - 중복 없음 - 정상 저장") + void 닉네임_변경_중복없음_성공() { + MemberProfile existingProfile = MemberProfile.builder() + .member(member).nickname("기존닉네임") + .gender(Gender.MALE).age(Age.TWENTY) + .mbtiIe(MbtiIe.E).mbtiTf(MbtiTf.T).mbti("ENTP") + .build(); + + MemberProfileRequest request = new MemberProfileRequest( + "새닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, "ENTP" + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(existingProfile)); + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("새닉네임")).thenReturn(false); + + memberProfileService.saveOrUpdateProfile(request); + + verify(memberProfileRepository, times(1)).existsByNicknameAndDeletedAtIsNull("새닉네임"); verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); + assertThat(existingProfile.getNickname()).isEqualTo("새닉네임"); } @Test - @DisplayName("테스트 7: 닉네임 동일, MBTI 정상 변경 - 정상 저장") - void 닉네임_동일_MBTI_변경_성공() { + @DisplayName("getProfile() 메서드가 포인트 정보를 포함해서 반환하는지 확인") + void getProfile_포인트_정보_포함_확인() { // given - MemberProfile existingProfile = MemberProfile.builder() + MemberProfile profile = MemberProfile.builder() .member(member) - .nickname("기존닉네임") + .nickname("테스트닉네임") .gender(Gender.MALE) .age(Age.TWENTY) .mbtiIe(MbtiIe.E) .mbtiTf(MbtiTf.T) .mbti("ENTP") + .point(100L) .build(); + profile.getMemberProfileTitles().add(equippedProfileTitle(profile, "균형의 달인")); - MemberProfileRequest request = new MemberProfileRequest( - "기존닉네임", // 닉네임 변경 없음 - Gender.MALE, - Age.TWENTY, - MbtiIe.I, - MbtiTf.F, - "INFP" // MBTI만 변경 - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.of(existingProfile)); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); // when - memberProfileService.saveOrUpdateProfile(request); + var response = memberProfileService.getProfile(); // then - // 닉네임 동일하므로 중복 체크 호출 안됨 - verify(memberProfileRepository, never()).existsByNickname(any()); - - // 저장은 1회 호출 - verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); - - // MBTI가 변경되었는지 확인 - assertThat(existingProfile.getMbti()).isEqualTo("INFP"); + assertThat(response).isNotNull(); + assertThat(response.profile()).isNotNull(); + assertThat(response.profile().point()).isEqualTo(100L); + assertThat(response.profile().nickname()).isEqualTo("테스트닉네임"); + assertThat(response.profile().title()).isEqualTo("균형의 달인"); } @Test - @DisplayName("테스트 8: 닉네임 변경하면서 중복 없음 - 정상 저장") - void 닉네임_변경_중복없음_성공() { + @DisplayName("getMyProfile() 메서드가 포인트 정보를 포함해서 반환하는지 확인") + void getMyProfile_포인트_정보_포함_확인() { // given - MemberProfile existingProfile = MemberProfile.builder() + MemberProfile profile = MemberProfile.builder() .member(member) - .nickname("기존닉네임") + .nickname("테스트닉네임") .gender(Gender.MALE) .age(Age.TWENTY) .mbtiIe(MbtiIe.E) .mbtiTf(MbtiTf.T) .mbti("ENTP") + .point(150L) .build(); + profile.getMemberProfileTitles().add(equippedProfileTitle(profile, "선택의 신")); - MemberProfileRequest request = new MemberProfileRequest( - "새닉네임", // 닉네임 변경 - Gender.MALE, - Age.TWENTY, - MbtiIe.E, - MbtiTf.T, - "ENTP" - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.of(existingProfile)); - when(memberProfileRepository.existsByNickname("새닉네임")) - .thenReturn(false); // 중복 없음 + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); // when - memberProfileService.saveOrUpdateProfile(request); + var response = memberProfileService.getMyProfile(); // then - // 닉네임이 변경되었으므로 중복 체크 호출됨 - verify(memberProfileRepository, times(1)).existsByNickname("새닉네임"); + assertThat(response).isNotNull(); + assertThat(response.profile()).isNotNull(); + assertThat(response.profile().point()).isEqualTo(150L); + assertThat(response.profile().nickname()).isEqualTo("테스트닉네임"); + assertThat(response.profile().title()).isEqualTo("선택의 신"); + } - // 저장은 1회 호출 - verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); + private MemberProfileTitle equippedProfileTitle(MemberProfile profile, String titleName) { + Title title = Title.builder() + .code(titleName) + .name(titleName) + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.DEFAULT) + .build(); - // 닉네임이 변경되었는지 확인 - assertThat(existingProfile.getNickname()).isEqualTo("새닉네임"); + MemberProfileTitle profileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build(); + profileTitle.equip(); + + return profileTitle; } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java index 6756426..8c42706 100644 --- a/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java @@ -2,9 +2,12 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; 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; @@ -20,7 +23,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -// Repository(db) 에 의존적인 메서드는 테스트 하지 않는다. @ExtendWith(MockitoExtension.class) class MemberServiceImplTest { @@ -30,42 +32,73 @@ class MemberServiceImplTest { @Mock private MemberRepository memberRepository; + @Mock + private MemberProfileRepository memberProfileRepository; + @BeforeEach void setupSecurityContext() { Authentication authentication = mock(Authentication.class); - when(authentication.getName()).thenReturn("1"); // userId=1 가정 + when(authentication.getName()).thenReturn("1"); SecurityContext securityContext = mock(SecurityContext.class); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); } @Test - void 멤버삭제_test() { + @DisplayName("회원 탈퇴 시 Member와 MemberProfile 모두 soft delete 처리") + void 멤버삭제_MemberProfile도_함께_softDelete() { // given Member member = new Member(); + MemberProfile profile = new MemberProfile(); - when(memberRepository.findByIdAndDeletedAtIsNull(any())) + when(memberRepository.findByIdAndDeletedAtIsNull(1L)) + .thenReturn(Optional.of(member)); + when(memberRepository.save(any(Member.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.of(profile)); + + // when + Member deletedMember = memberService.deleteMemberById(); + + // then + assertNotNull(deletedMember.getDeletedAt(), "Member의 deletedAt이 설정되어야 한다"); + assertNotNull(profile.getDeletedAt(), "MemberProfile의 deletedAt도 함께 설정되어야 한다"); + verify(memberRepository, times(1)).save(member); + verify(memberProfileRepository, times(1)).findByMemberId(1L); + } + + @Test + @DisplayName("MemberProfile이 없는 회원도 탈퇴 정상 처리") + void 멤버삭제_프로필없어도_정상처리() { + // given + Member member = new Member(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); when(memberRepository.save(any(Member.class))) .thenAnswer(invocation -> invocation.getArgument(0)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.empty()); // 프로필 없음 // when Member deletedMember = memberService.deleteMemberById(); - // then: member 객체 deletedAt 속성 검증 및 member 저장 1회 검증 - assertNotNull(deletedMember.getDeletedAt()); - verify(memberRepository).save(member); + // then + assertNotNull(deletedMember.getDeletedAt(), "Member의 deletedAt이 설정되어야 한다"); + verify(memberRepository, times(1)).save(member); } @Test - void 멤버삭제실패_test() { + @DisplayName("존재하지 않는 회원 탈퇴 시 ApiException 발생") + void 멤버삭제실패_존재하지않는_회원() { // given when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.empty()); - // when & then : Exception 검증 + // when & then ApiException exception = assertThrows(ApiException.class, () -> memberService.deleteMemberById()); assertEquals("사용자를 찾을 수 없습니다", exception.getMessage()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java new file mode 100644 index 0000000..f6739a4 --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java @@ -0,0 +1,210 @@ +package com.valanse.valanse.service.PointService; + +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.PointHistory; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.PointHistoryRepository; +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 java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PointServiceImplTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private MemberProfileRepository memberProfileRepository; + + @Mock + private PointHistoryRepository pointHistoryRepository; + + @InjectMocks + private PointServiceImpl pointService; + + @Test + @DisplayName("포인트 히스토리 조회 - 성공") + void getPointHistory_Success() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + List<PointHistory> histories = Arrays.asList( + PointHistory.builder() + .id(1L) + .member(member) + .amount(40L) + .remainingPoint(40L) + .type(PointType.SIGN_UP) + .build(), + PointHistory.builder() + .id(2L) + .member(member) + .amount(5L) + .remainingPoint(45L) + .type(PointType.POST_CREATE) + .build(), + PointHistory.builder() + .id(3L) + .member(member) + .amount(1L) + .remainingPoint(46L) + .type(PointType.COMMENT_CREATE) + .build() + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberId(memberId)) + .thenReturn(histories); + + // When + PointHistoryResponse response = pointService.getPointHistory(memberId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.pointHistory()).hasSize(3); + + PointHistoryResponse.PointHistoryItem firstItem = response.pointHistory().get(0); + assertThat(firstItem.amount()).isEqualTo(40L); + assertThat(firstItem.remainingPoint()).isEqualTo(40L); + assertThat(firstItem.type()).isEqualTo(PointType.SIGN_UP); + assertThat(firstItem.typeDescription()).isEqualTo("회원가입"); + } + + @Test + @DisplayName("포인트 히스토리 조회 - 회원이 존재하지 않는 경우") + void getPointHistory_MemberNotFound() { + // Given + Long memberId = 999L; + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> pointService.getPointHistory(memberId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("회원을 찾을 수 없습니다."); + } + + @Test + @DisplayName("포인트 타입 설명 확인") + void getPointTypeDescription_AllTypes() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + List<PointHistory> histories = Arrays.asList( + PointHistory.builder().id(1L).member(member).amount(40L).type(PointType.SIGN_UP).build(), + PointHistory.builder().id(2L).member(member).amount(5L).type(PointType.POST_CREATE).build(), + PointHistory.builder().id(3L).member(member).amount(1L).type(PointType.COMMENT_CREATE).build(), + PointHistory.builder().id(4L).member(member).amount(1L).type(PointType.POST_VOTED).build(), + PointHistory.builder().id(5L).member(member).amount(50L).type(PointType.HOT_ISSUE).build(), + PointHistory.builder().id(6L).member(member).amount(-300L).type(PointType.TITLE_PURCHASE).build() + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberId(memberId)) + .thenReturn(histories); + + // When + PointHistoryResponse response = pointService.getPointHistory(memberId); + + // Then + List<PointHistoryResponse.PointHistoryItem> items = response.pointHistory(); + assertThat(items.get(0).typeDescription()).isEqualTo("회원가입"); + assertThat(items.get(1).typeDescription()).isEqualTo("게시글 작성"); + assertThat(items.get(2).typeDescription()).isEqualTo("댓글 작성"); + assertThat(items.get(3).typeDescription()).isEqualTo("투표 참여"); + assertThat(items.get(4).typeDescription()).isEqualTo("핫이슈"); + assertThat(items.get(5).typeDescription()).isEqualTo("칭호 구매"); + } + + @Test + @DisplayName("포인트 사용 내역 기록 - 칭호 구매") + void recordPointUsage_TitlePurchase() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + MemberProfile profile = MemberProfile.builder() + .member(member) + .point(700L) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(memberId)) + .thenReturn(Optional.of(profile)); + + // When + pointService.recordPointUsage(memberId, 300L, PointType.TITLE_PURCHASE); + + // Then + verify(pointHistoryRepository).save(argThat(history -> + history.getMember() == member && + history.getAmount().equals(-300L) && + history.getRemainingPoint().equals(700L) && + history.getType() == PointType.TITLE_PURCHASE + )); + } + + @Test + @DisplayName("createdAt 날짜 포맷 확인 - YYYY-MM-DD HH:mm:ss 형식") + void getPointHistory_DateFormatting() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + LocalDateTime testDateTime = LocalDateTime.of(2026, 4, 26, 15, 30, 22); + + // PointHistory 객체를 mock으로 생성 + PointHistory mockHistory = mock(PointHistory.class); + when(mockHistory.getId()).thenReturn(1L); + when(mockHistory.getAmount()).thenReturn(40L); + when(mockHistory.getRemainingPoint()).thenReturn(40L); + when(mockHistory.getType()).thenReturn(PointType.SIGN_UP); + when(mockHistory.getCreatedAt()).thenReturn(testDateTime); + + List<PointHistory> histories = Arrays.asList(mockHistory); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberId(memberId)) + .thenReturn(histories); + + // When + PointHistoryResponse response = pointService.getPointHistory(memberId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.pointHistory()).hasSize(1); + + PointHistoryResponse.PointHistoryItem item = response.pointHistory().get(0); + // 날짜가 "2026-04-26 15:30:22" 형식으로 포맷되었는지 확인 + assertThat(item.createdAt()).isEqualTo("2026-04-26 15:30:22"); + assertThat(item.amount()).isEqualTo(40L); + assertThat(item.remainingPoint()).isEqualTo(40L); + assertThat(item.type()).isEqualTo(PointType.SIGN_UP); + assertThat(item.typeDescription()).isEqualTo("회원가입"); + } +} diff --git a/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java b/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java index eed9bdd..d742fe5 100644 --- a/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java @@ -9,6 +9,7 @@ import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.ReportRepository; import com.valanse.valanse.repository.VoteRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -21,114 +22,140 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ReportServiceImplTest { - @InjectMocks - private ReportServiceImpl reportService; - @Mock - private ReportRepository reportRepository; + @InjectMocks private ReportServiceImpl reportService; - @Mock - private VoteRepository voteRepository; + @Mock private ReportRepository reportRepository; + @Mock private VoteRepository voteRepository; + @Mock private CommentRepository commentRepository; - @Mock - private CommentRepository commentRepository; + // ────────────────────────────────────────────── + // 정상 신고 + // ────────────────────────────────────────────── @Test - void 댓글_신고() { - //given - Member member = Member.builder().id(1L).build(); + @DisplayName("타인의 댓글을 신고하면 Report가 저장된다") + void 댓글_신고_성공() { + Member reporter = Member.builder().id(1L).build(); Member writer = Member.builder().id(2L).build(); Comment comment = Comment.builder().member(writer).build(); - //stub when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(false); - //when - reportService.report(member, 1L, ReportType.COMMENT); + reportService.report(reporter, 1L, ReportType.COMMENT); - //then ArgumentCaptor<Report> captor = ArgumentCaptor.forClass(Report.class); verify(reportRepository).save(captor.capture()); - Report report = captor.getValue(); - assertEquals(member, report.getMember()); + assertThat(captor.getValue().getMember()).isEqualTo(reporter); } @Test - void 게임_신고() { - //given - Member member = Member.builder().id(1L).build(); + @DisplayName("타인의 투표를 신고하면 Report가 저장된다") + void 게임_신고_성공() { + Member reporter = Member.builder().id(1L).build(); Member writer = Member.builder().id(2L).build(); Vote vote = Vote.builder().member(writer).build(); - //stub when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(false); - //when - reportService.report(member, 1L, ReportType.VOTE); + reportService.report(reporter, 1L, ReportType.VOTE); - //then ArgumentCaptor<Report> captor = ArgumentCaptor.forClass(Report.class); verify(reportRepository).save(captor.capture()); - Report report = captor.getValue(); - assertEquals(member, report.getMember()); + assertThat(captor.getValue().getMember()).isEqualTo(reporter); } + // ────────────────────────────────────────────── + // 자기 자신 신고 불가 + // ────────────────────────────────────────────── + @Test + @DisplayName("본인 댓글은 신고할 수 없다") void 본인_댓글_신고_불가() { - //given Member writer = Member.builder().id(2L).build(); Comment comment = Comment.builder().member(writer).build(); - //stub when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); - //when - ApiException apiException = assertThrows(ApiException.class, () -> reportService.report(writer, 1L, ReportType.COMMENT)); - - //then - assertThat(apiException.getMessage()).isEqualTo("자신의 댓글은 신고할 수 없습니다."); + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(writer, 1L, ReportType.COMMENT)); + assertThat(ex.getMessage()).isEqualTo("자신의 댓글은 신고할 수 없습니다."); } @Test + @DisplayName("본인 투표는 신고할 수 없다") void 본인_게임_신고_불가() { - //given Member writer = Member.builder().id(2L).build(); Vote vote = Vote.builder().member(writer).build(); - //stub when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); - //when - ApiException apiException = assertThrows(ApiException.class, () -> reportService.report(writer, 1L, ReportType.VOTE)); - - //then - assertThat(apiException.getMessage()).isEqualTo("자신의 투표는 신고할 수 없습니다."); + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(writer, 1L, ReportType.VOTE)); + assertThat(ex.getMessage()).isEqualTo("자신의 투표는 신고할 수 없습니다."); } + // ────────────────────────────────────────────── + // 중복 신고 방지 + // ────────────────────────────────────────────── + @Test + @DisplayName("같은 댓글을 두 번 신고하면 예외가 발생한다") void 중복_신고_불가() { - //given - Member member = Member.builder().id(1L).build(); + Member reporter = Member.builder().id(1L).build(); Member writer = Member.builder().id(2L).build(); Comment comment = Comment.builder().member(writer).build(); - //stub when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(true); - //when - ApiException apiException = assertThrows(ApiException.class, () -> reportService.report(member, 1L, ReportType.COMMENT)); + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(reporter, 1L, ReportType.COMMENT)); + assertThat(ex.getMessage()).isEqualTo("이미 신고한 대상입니다."); + } + + @Test + @DisplayName("같은 투표를 두 번 신고하면 예외가 발생한다") + void 중복_게임_신고_불가() { + Member reporter = Member.builder().id(1L).build(); + Member writer = Member.builder().id(2L).build(); + Vote vote = Vote.builder().member(writer).build(); - //then - assertThat(apiException.getMessage()).isEqualTo("이미 신고한 대상입니다."); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(reporter, 1L, ReportType.VOTE)); + assertThat(ex.getMessage()).isEqualTo("이미 신고한 대상입니다."); } + // ────────────────────────────────────────────── + // 존재하지 않는 대상 신고 + // ────────────────────────────────────────────── -} \ No newline at end of file + @Test + @DisplayName("존재하지 않는 댓글을 신고하면 예외가 발생한다") + void 존재하지않는_댓글_신고() { + Member reporter = Member.builder().id(1L).build(); + when(commentRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(Exception.class, () -> reportService.report(reporter, 999L, ReportType.COMMENT)); + verify(reportRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 투표를 신고하면 예외가 발생한다") + void 존재하지않는_투표_신고() { + Member reporter = Member.builder().id(1L).build(); + when(voteRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(Exception.class, () -> reportService.report(reporter, 999L, ReportType.VOTE)); + verify(reportRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java new file mode 100644 index 0000000..86305f2 --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -0,0 +1,604 @@ +package com.valanse.valanse.service.TitleService; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.TitleRepository; +import com.valanse.valanse.service.PointService.PointService; +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 java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TitleServiceImplTest { + + @InjectMocks + private TitleServiceImpl titleService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private MemberProfileRepository memberProfileRepository; + + @Mock + private MemberProfileTitleRepository memberProfileTitleRepository; + + @Mock + private TitleRepository titleRepository; + + @Mock + private PointService pointService; + + private Member member; + private MemberProfile profile; + + @BeforeEach + void setup() { + member = Member.builder() + .id(1L) + .email("test@email.com") + .nickname("테스터") + .name("test") + .build(); + profile = MemberProfile.builder() + .id(1L) + .member(member) + .nickname("테스트닉네임") + .build(); + } + + @Test + @DisplayName("getTitleList()는 기본, 보유, 미보유 칭호를 분리해서 반환한다") + void getTitleList_기본_보유_미보유_분리() { + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + Title ownedTitle = title(2L, "싸움 구경꾼", TitleAcquisitionType.ACHIEVEMENT, 0L, "투표 10회 참여"); + Title pointTitle = title(3L, "선택의 신", TitleAcquisitionType.POINT_PURCHASE, 300L, null); + Title seasonTitle = title(4L, "2026 봄 논쟁왕", TitleAcquisitionType.SEASON, 0L, "시즌한정"); + MemberProfileTitle ownedProfileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(ownedTitle) + .build(); + ownedProfileTitle.equip(); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc()) + .thenReturn(List.of(defaultTitle, ownedTitle, pointTitle, seasonTitle)); + when(memberProfileTitleRepository.findAllByMemberProfileMemberId(1L)) + .thenReturn(List.of(ownedProfileTitle)); + when(memberProfileTitleRepository.saveAll(org.mockito.ArgumentMatchers.anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var response = titleService.getTitleList(1L); + + assertThat(response.defaultTitles()).hasSize(1); + assertThat(response.defaultTitles().get(0).title()).isEqualTo("밸런스 새싹"); + assertThat(response.defaultTitles().get(0).owned()).isTrue(); + assertThat(response.defaultTitles().get(0).locked()).isFalse(); + + assertThat(response.ownedTitles()).hasSize(1); + assertThat(response.ownedTitles().get(0).title()).isEqualTo("싸움 구경꾼"); + assertThat(response.ownedTitles().get(0).equipped()).isTrue(); + + assertThat(response.lockedTitles()).hasSize(2); + assertThat(response.lockedTitles().get(0).title()).isEqualTo("선택의 신"); + assertThat(response.lockedTitles().get(0).lockReason()).isEqualTo("300P 필요"); + assertThat(response.lockedTitles().get(1).title()).isEqualTo("2026 봄 논쟁왕"); + assertThat(response.lockedTitles().get(1).lockReason()).isEqualTo("시즌한정"); + } + + @Test + @DisplayName("getTitleList()는 누락된 기본 칭호를 자동 지급한다") + void getTitleList_기본칭호_자동지급() { + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc()).thenReturn(List.of(defaultTitle)); + when(memberProfileTitleRepository.findAllByMemberProfileMemberId(1L)).thenReturn(List.of()); + when(memberProfileTitleRepository.saveAll(org.mockito.ArgumentMatchers.anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var response = titleService.getTitleList(1L); + + assertThat(response.defaultTitles()).hasSize(1); + assertThat(response.defaultTitles().get(0).owned()).isTrue(); + verify(memberProfileTitleRepository).saveAll(org.mockito.ArgumentMatchers.argThat(savedTitles -> + containsOnlyTitle(savedTitles, defaultTitle.getId()) + )); + } + + @Test + @DisplayName("equipTitle()은 기존 장착 칭호를 해제하고 선택한 칭호를 장착한다") + void equipTitle_기존칭호해제_선택칭호장착() { + MemberProfileTitle equippedTitle = equippedProfileTitle(1L, "기존 칭호"); + MemberProfileTitle targetTitle = profileTitle(2L, "새 칭호"); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 2L)) + .thenReturn(Optional.of(targetTitle)); + when(memberProfileTitleRepository.findAllByMemberProfileMemberIdAndEquippedTrue(1L)) + .thenReturn(List.of(equippedTitle)); + + var response = titleService.equipTitle(1L, 2L); + + assertThat(equippedTitle.isEquipped()).isFalse(); + assertThat(targetTitle.isEquipped()).isTrue(); + assertThat(response.titleId()).isEqualTo(2L); + assertThat(response.title()).isEqualTo("새 칭호"); + assertThat(response.equipped()).isTrue(); + } + + @Test + @DisplayName("equipTitle()은 보유하지 않은 칭호를 장착할 수 없다") + void equipTitle_보유하지않은칭호_예외() { + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 99L)) + .thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.equipTitle(1L, 99L) + ); + + assertThat(exception.getMessage()).isEqualTo("보유하지 않은 칭호입니다."); + verify(memberProfileTitleRepository, never()).findAllByMemberProfileMemberIdAndEquippedTrue(1L); + } + + @Test + @DisplayName("equipTitle()은 삭제된 칭호를 장착할 수 없다") + void equipTitle_삭제된칭호_예외() { + Title inactiveTitle = Title.builder() + .id(99L) + .code("DELETED_TITLE") + .name("삭제된 칭호") + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.ACHIEVEMENT) + .active(false) + .build(); + MemberProfileTitle inactiveProfileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(inactiveTitle) + .build(); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 99L)) + .thenReturn(Optional.of(inactiveProfileTitle)); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.equipTitle(1L, 99L) + ); + + assertThat(exception.getMessage()).isEqualTo("장착할 수 없는 칭호입니다."); + verify(memberProfileTitleRepository, never()).findAllByMemberProfileMemberIdAndEquippedTrue(1L); + } + + @Test + @DisplayName("equipTitle()은 프로필이 없으면 예외를 던진다") + void equipTitle_프로필없음_예외() { + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.equipTitle(1L, 1L) + ); + + assertThat(exception.getMessage()).isEqualTo("프로필이 존재하지 않습니다."); + verify(memberProfileTitleRepository, never()).findByMemberProfileMemberIdAndTitleId(1L, 1L); + } + + @Test + @DisplayName("purchaseTitle()은 포인트 구매형 칭호를 구매하고 포인트를 차감한다") + void purchaseTitle_구매성공_포인트차감() { + profile.addPoint(500L); + Title pointTitle = title(3L, "선택의 신", TitleAcquisitionType.POINT_PURCHASE, 300L, null); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findById(3L)).thenReturn(Optional.of(pointTitle)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 3L)) + .thenReturn(Optional.empty()); + + var response = titleService.purchaseTitle(1L, 3L); + + assertThat(profile.getPoint()).isEqualTo(200L); + assertThat(response.titleId()).isEqualTo(3L); + assertThat(response.title()).isEqualTo("선택의 신"); + assertThat(response.owned()).isTrue(); + assertThat(response.remainingPoint()).isEqualTo(200L); + verify(pointService).recordPointUsage(1L, 300L, PointType.TITLE_PURCHASE); + verify(memberProfileTitleRepository).save(org.mockito.ArgumentMatchers.argThat(savedTitle -> + savedTitle.getMemberProfile() == profile && savedTitle.getTitle().getId().equals(3L) + )); + } + + @Test + @DisplayName("purchaseTitle()은 포인트가 부족하면 필요 포인트와 함께 예외를 던진다") + void purchaseTitle_포인트부족_예외() { + profile.addPoint(100L); + Title pointTitle = title(3L, "선택의 신", TitleAcquisitionType.POINT_PURCHASE, 300L, null); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findById(3L)).thenReturn(Optional.of(pointTitle)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 3L)) + .thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.purchaseTitle(1L, 3L) + ); + + assertThat(exception.getMessage()).isEqualTo("포인트가 부족합니다. (필요포인트 300P 필요)"); + assertThat(profile.getPoint()).isEqualTo(100L); + verify(pointService, never()).recordPointUsage(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + verify(memberProfileTitleRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("createTitle()은 관리자가 새로운 칭호를 생성한다") + void createTitle_관리자_칭호생성() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + TitleCreateRequest request = new TitleCreateRequest( + " CHOICE_MASTER ", + " 선택의 달인 ", + "투표 참여 고수", + 300L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "300P 필요", + true, + 10 + ); + Title savedTitle = Title.builder() + .id(10L) + .code("CHOICE_MASTER") + .name("선택의 달인") + .description("투표 참여 고수") + .price(300L) + .tier(TitleTier.RARE) + .acquisitionType(TitleAcquisitionType.POINT_PURCHASE) + .requirementText("300P 필요") + .active(true) + .displayOrder(10) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.existsByCode("CHOICE_MASTER")).thenReturn(false); + when(titleRepository.save(any(Title.class))).thenReturn(savedTitle); + + var response = titleService.createTitle(1L, request); + + assertThat(response.titleId()).isEqualTo(10L); + assertThat(response.code()).isEqualTo("CHOICE_MASTER"); + assertThat(response.title()).isEqualTo("선택의 달인"); + assertThat(response.price()).isEqualTo(300L); + assertThat(response.acquisitionType()).isEqualTo(TitleAcquisitionType.POINT_PURCHASE); + verify(titleRepository).save(org.mockito.ArgumentMatchers.argThat(title -> + title.getCode().equals("CHOICE_MASTER") + && title.getName().equals("선택의 달인") + && title.getPrice() == 300L + && title.getTier() == TitleTier.RARE + && title.getAcquisitionType() == TitleAcquisitionType.POINT_PURCHASE + && title.getDisplayOrder() == 10 + )); + } + + @Test + @DisplayName("getTitleListForAdmin()은 관리자에게 칭호 마스터 데이터 목록을 반환한다") + void getTitleListForAdmin_관리자_칭호목록조회() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title firstTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + Title secondTitle = title(2L, "선택의 달인", TitleAcquisitionType.POINT_PURCHASE, 300L, "300P 필요"); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findAllByOrderByDisplayOrderAscIdAsc()).thenReturn(List.of(firstTitle, secondTitle)); + + var response = titleService.getTitleListForAdmin(1L); + + assertThat(response).hasSize(2); + assertThat(response.get(0).titleId()).isEqualTo(1L); + assertThat(response.get(0).titleName()).isEqualTo("밸런스 새싹"); + assertThat(response.get(1).titleId()).isEqualTo(2L); + assertThat(response.get(1).price()).isEqualTo(300L); + assertThat(response.get(1).requirementText()).isEqualTo("300P 필요"); + } + + @Test + @DisplayName("createTitle()은 관리자가 아니면 예외를 던진다") + void createTitle_관리자아님_예외() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.createTitle(1L, validCreateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("관리자만 접근 가능합니다."); + verify(titleRepository, never()).save(any()); + } + + @Test + @DisplayName("createTitle()은 중복된 칭호 코드를 생성할 수 없다") + void createTitle_중복코드_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.existsByCode("CHOICE_MASTER")).thenReturn(true); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.createTitle(1L, validCreateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("이미 존재하는 칭호 코드입니다."); + verify(titleRepository, never()).save(any()); + } + + @Test + @DisplayName("createTitle()은 필수값이 없으면 예외를 던진다") + void createTitle_필수값없음_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + TitleCreateRequest request = new TitleCreateRequest( + "CHOICE_MASTER", + " ", + null, + null, + TitleTier.BASIC, + TitleAcquisitionType.ACHIEVEMENT, + null, + null, + null + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.createTitle(1L, request) + ); + + assertThat(exception.getMessage()).isEqualTo("칭호 이름을 입력해주세요."); + verify(titleRepository, never()).existsByCode("CHOICE_MASTER"); + verify(titleRepository, never()).save(any()); + } + + @Test + @DisplayName("updateTitle()은 관리자가 칭호를 수정한다") + void updateTitle_관리자_칭호수정() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title targetTitle = title(2L, "기존 칭호", TitleAcquisitionType.ACHIEVEMENT, 0L, null); + TitleUpdateRequest request = new TitleUpdateRequest( + " UPDATED_TITLE ", + " 수정된 칭호 ", + "수정된 설명", + 500L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "500P 필요", + false, + 20 + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(2L)).thenReturn(Optional.of(targetTitle)); + when(titleRepository.existsByCodeAndIdNot("UPDATED_TITLE", 2L)).thenReturn(false); + + var response = titleService.updateTitle(1L, 2L, request); + + assertThat(response.titleId()).isEqualTo(2L); + assertThat(response.code()).isEqualTo("UPDATED_TITLE"); + assertThat(response.title()).isEqualTo("수정된 칭호"); + assertThat(response.description()).isEqualTo("수정된 설명"); + assertThat(response.price()).isEqualTo(500L); + assertThat(response.tier()).isEqualTo(TitleTier.RARE); + assertThat(response.acquisitionType()).isEqualTo(TitleAcquisitionType.POINT_PURCHASE); + assertThat(response.requirementText()).isEqualTo("500P 필요"); + assertThat(response.active()).isFalse(); + assertThat(response.displayOrder()).isEqualTo(20); + assertThat(targetTitle.getCode()).isEqualTo("UPDATED_TITLE"); + assertThat(targetTitle.getName()).isEqualTo("수정된 칭호"); + } + + @Test + @DisplayName("updateTitle()은 중복된 칭호 코드로 수정할 수 없다") + void updateTitle_중복코드_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title targetTitle = title(2L, "기존 칭호", TitleAcquisitionType.ACHIEVEMENT, 0L, null); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(2L)).thenReturn(Optional.of(targetTitle)); + when(titleRepository.existsByCodeAndIdNot("CHOICE_MASTER", 2L)).thenReturn(true); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.updateTitle(1L, 2L, validUpdateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("이미 존재하는 칭호 코드입니다."); + assertThat(targetTitle.getCode()).isEqualTo("TITLE_2"); + } + + @Test + @DisplayName("updateTitle()은 관리자가 아니면 예외를 던진다") + void updateTitle_관리자아님_예외() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.updateTitle(1L, 2L, validUpdateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("관리자만 접근 가능합니다."); + verify(titleRepository, never()).findById(2L); + } + + @Test + @DisplayName("deleteTitle()은 관리자가 칭호를 삭제하고 장착 회원을 기본 칭호로 변경한다") + void deleteTitle_관리자_장착회원_기본칭호변경() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title targetTitle = title(2L, "시즌 칭호", TitleAcquisitionType.SEASON, 0L, null); + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + MemberProfileTitle equippedTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(targetTitle) + .build(); + equippedTitle.equip(); + MemberProfileTitle fallbackProfileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(defaultTitle) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(2L)).thenReturn(Optional.of(targetTitle)); + when(titleRepository.findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT)) + .thenReturn(Optional.of(defaultTitle)); + when(memberProfileTitleRepository.findAllByTitleIdAndEquippedTrue(2L)) + .thenReturn(List.of(equippedTitle)); + when(memberProfileTitleRepository.findByMemberProfileIdAndTitleId(1L, 1L)) + .thenReturn(Optional.of(fallbackProfileTitle)); + + var response = titleService.deleteTitle(1L, 2L); + + assertThat(targetTitle.isActive()).isFalse(); + assertThat(equippedTitle.isEquipped()).isFalse(); + assertThat(fallbackProfileTitle.isEquipped()).isTrue(); + assertThat(response.deletedTitleId()).isEqualTo(2L); + assertThat(response.fallbackTitleId()).isEqualTo(1L); + assertThat(response.reassignedCount()).isEqualTo(1); + assertThat(response.active()).isFalse(); + } + + @Test + @DisplayName("deleteTitle()은 기본 칭호를 삭제할 수 없다") + void deleteTitle_기본칭호_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(1L)).thenReturn(Optional.of(defaultTitle)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.deleteTitle(1L, 1L) + ); + + assertThat(exception.getMessage()).isEqualTo("기본 칭호는 삭제할 수 없습니다."); + assertThat(defaultTitle.isActive()).isTrue(); + verify(memberProfileTitleRepository, never()).findAllByTitleIdAndEquippedTrue(1L); + } + + @Test + @DisplayName("deleteTitle()은 관리자가 아니면 예외를 던진다") + void deleteTitle_관리자아님_예외() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.deleteTitle(1L, 2L) + ); + + assertThat(exception.getMessage()).isEqualTo("관리자만 접근 가능합니다."); + verify(titleRepository, never()).findById(2L); + } + + private MemberProfileTitle equippedProfileTitle(Long titleId, String titleName) { + MemberProfileTitle profileTitle = profileTitle(titleId, titleName); + profileTitle.equip(); + return profileTitle; + } + + private MemberProfileTitle profileTitle(Long titleId, String titleName) { + Title title = title(titleId, titleName, TitleAcquisitionType.DEFAULT, 0L, null); + + return MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build(); + } + + private Title title( + Long titleId, + String titleName, + TitleAcquisitionType acquisitionType, + long price, + String requirementText + ) { + return Title.builder() + .id(titleId) + .code("TITLE_" + titleId) + .name(titleName) + .tier(TitleTier.BASIC) + .acquisitionType(acquisitionType) + .price(price) + .requirementText(requirementText) + .build(); + } + + private TitleCreateRequest validCreateRequest() { + return new TitleCreateRequest( + "CHOICE_MASTER", + "선택의 달인", + "투표 참여 고수", + 300L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "300P 필요", + true, + 10 + ); + } + + private TitleUpdateRequest validUpdateRequest() { + return new TitleUpdateRequest( + "CHOICE_MASTER", + "선택의 달인", + "투표 참여 고수", + 300L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "300P 필요", + true, + 10 + ); + } + + private boolean containsOnlyTitle(Iterable<MemberProfileTitle> savedTitles, Long titleId) { + int count = 0; + boolean matched = false; + for (MemberProfileTitle savedTitle : savedTitles) { + count++; + matched = savedTitle.getTitle().getId().equals(titleId); + } + return count == 1 && matched; + } +} diff --git a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java index a28d9a4..16f4883 100644 --- a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java @@ -5,12 +5,17 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.VoteOption; +import com.valanse.valanse.domain.enums.PinType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteCategory; +import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.domain.mapping.MemberVoteOption; import com.valanse.valanse.dto.Vote.VoteCancleResponseDto; import com.valanse.valanse.dto.Vote.VoteCreateRequest; +import com.valanse.valanse.dto.Vote.VoteDetailResponse; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; +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; @@ -19,6 +24,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.Optional; @@ -34,64 +42,84 @@ class VoteServiceImplTest { @InjectMocks private VoteServiceImpl voteService; - @Mock - private VoteRepository voteRepository; - @Mock - private MemberRepository memberRepository; - @Mock - private MemberVoteOptionRepository memberVoteOptionRepository; - @Mock - private CommentGroupRepository commentGroupRepository; - @Mock - private VoteOptionRepository voteOptionRepository; + @Mock private VoteRepository voteRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberVoteOptionRepository memberVoteOptionRepository; + @Mock private CommentGroupRepository commentGroupRepository; + @Mock private VoteOptionRepository voteOptionRepository; + @Mock private MemberProfileRepository memberProfileRepository; + @Mock private MemberProfileTitleRepository memberProfileTitleRepository; + @Mock private PointService pointService; + + // ────────────────────────────────────────────── + // createVote + // ────────────────────────────────────────────── @Test - @DisplayName("투표 제목은 너무 길거나 너무 짧으면 안된다.") - void 제목길이실패_test() { + @DisplayName("투표 제목이 비어있으면 예외가 발생한다") + void 제목길이실패_빈제목() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); - //given - Member member = new Member(); - when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + VoteCreateRequest request = VoteCreateRequest.builder().title("").build(); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("공백만으로 된 제목은 비어있는 것으로 처리된다") + void 제목길이실패_공백만있는제목() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); + + VoteCreateRequest request = VoteCreateRequest.builder().title(" ").build(); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + } + + @Test + @DisplayName("투표 제목이 25자를 초과하면 예외가 발생한다") + void 제목길이실패_26자() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); - // 주석 부분 바꿔가며 확인 가능 VoteCreateRequest request = VoteCreateRequest.builder() -// .title("123123123123123123123123123123") - .title("") + .title("123456789012345678901234567890") // 30자 .build(); - // when - ApiException exception = assertThrows(ApiException.class, - () -> voteService.createVote(1L, request)); + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + } - // then - assertThat(exception.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + @Test + @DisplayName("투표 옵션이 null이면 예외가 발생한다") + void 투표옵션실패_null() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); + + VoteCreateRequest request = VoteCreateRequest.builder() + .title("테스트 투표").options(null).build(); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 옵션은 1개 이상 4개 이하여야 합니다."); } @Test - @DisplayName("투표 옵션은 너무 많아도, 너무 적어도 안된다.") - void 투표옵션실패_test() { - // given - Member member = new Member(); - when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + @DisplayName("투표 옵션이 5개 이상이면 예외가 발생한다") + void 투표옵션실패_5개() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); - // 주석 부분 바꿔가며 테스트 가능 VoteCreateRequest request = VoteCreateRequest.builder() - .title("테스트용 투표") -// .options(List.of("1번", "2번", "3번", "4번", "5번")) - .options(null) + .title("테스트 투표") + .options(List.of("1", "2", "3", "4", "5")) .build(); - // when - ApiException exception = assertThrows(ApiException.class, - () -> voteService.createVote(1L, request)); - //then - assertThat(exception.getMessage()).isEqualTo("투표 옵션은 1개 이상 4개 이하여야 합니다."); - assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 옵션은 1개 이상 4개 이하여야 합니다."); } @Test - void 투표생성_test() { - // given + @DisplayName("투표 생성 시 voteRepository, commentGroupRepository, pointService가 각 1회 호출된다") + void 투표생성_성공() { Member member = new Member(); VoteCreateRequest request = VoteCreateRequest.builder() @@ -100,214 +128,292 @@ class VoteServiceImplTest { .category(VoteCategory.ALL) .build(); - Vote vote = Vote.builder() - .id(100L) - .title(request.getTitle()) - .content(request.getContent()) - .category(request.getCategory()) - .member(member) - .build(); + Vote savedVote = Vote.builder() + .id(100L).title(request.getTitle()) + .category(request.getCategory()).member(member).build(); - //stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); - when(voteRepository.save(any())).thenReturn(vote); + when(voteRepository.save(any())).thenReturn(savedVote); - // when Long voteId = voteService.createVote(1L, request); - // then assertThat(voteId).isEqualTo(100L); - verify(voteRepository).save(any(Vote.class)); - verify(commentGroupRepository).save(any(CommentGroup.class)); + ArgumentCaptor<Vote> voteCaptor = ArgumentCaptor.forClass(Vote.class); + verify(voteRepository).save(voteCaptor.capture()); + assertThat(voteCaptor.getValue().getVoteOptions()).hasSize(4); - ArgumentCaptor<Vote> voteArgumentCaptor = ArgumentCaptor.forClass(Vote.class); - verify(voteRepository).save(voteArgumentCaptor.capture()); - Vote captorVote = voteArgumentCaptor.getValue(); - assertThat(captorVote.getVoteOptions()).hasSize(4); + verify(commentGroupRepository).save(any(CommentGroup.class)); + verify(pointService, times(1)).givePoint(any(), any()); } - @Test - void 처음투표하기_test() { - // given - Member member = Member.builder() - .id(1L) - .build(); - Vote vote = Vote.builder() - .id(10L) - .totalVoteCount(7) - .build(); - - VoteOption voteOption = VoteOption.builder() - .id(100L) - .voteCount(3) - .vote(vote) - .build(); + // ────────────────────────────────────────────── + // processVote + // ────────────────────────────────────────────── + @Test + @DisplayName("처음 투표 시 카운트가 증가하고 isVoted=true를 반환한다") + void 처음투표하기() { + Member member = Member.builder().id(1L).build(); + Member postOwner = Member.builder().id(99L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(7).member(postOwner).build(); + VoteOption voteOption = VoteOption.builder().id(100L).voteCount(3).vote(vote).build(); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); when(voteOptionRepository.findById(any())).thenReturn(Optional.of(voteOption)); when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.empty()); - // when - VoteCancleResponseDto responseDto = voteService.processVote(1L, 10L, 100L); + VoteCancleResponseDto result = voteService.processVote(1L, 10L, 100L); - // then : 응답 dto 속성 검증 - assertThat(responseDto.getTotalVoteCount()).isEqualTo(8); - assertThat(responseDto.getVoteOptionCount()).isEqualTo(4); - assertThat(responseDto.isVoted()).isTrue(); + assertThat(result.getTotalVoteCount()).isEqualTo(8); + assertThat(result.getVoteOptionCount()).isEqualTo(4); + assertThat(result.isVoted()).isTrue(); } @Test - void 투표취소_test() { + @DisplayName("처음 투표 시 본인 게시물이 아니면 작성자에게 포인트가 지급된다") + void 처음투표_포인트지급() { + Member voter = Member.builder().id(1L).build(); + Member postOwner = Member.builder().id(99L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(0).member(postOwner).build(); + VoteOption option = VoteOption.builder().id(100L).voteCount(0).vote(vote).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(voter)); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(option)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.empty()); - // given - Member member = Member.builder() - .id(1L) - .build(); + voteService.processVote(1L, 10L, 100L); - Vote vote = Vote.builder() - .id(10L) - .totalVoteCount(7) - .build(); + verify(pointService, times(1)).givePoint(eq(99L), any()); + } - VoteOption voteOption = VoteOption.builder() - .id(100L) - .voteCount(3) - .vote(vote) - .build(); + @Test + @DisplayName("본인 게시물에 투표해도 포인트가 지급되지 않는다") + void 처음투표_본인게시물_포인트미지급() { + Member member = Member.builder().id(1L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(0).member(member).build(); // 본인 게시물 + VoteOption option = VoteOption.builder().id(100L).voteCount(0).vote(vote).build(); - MemberVoteOption mvo = MemberVoteOption.builder() - .voteOption(voteOption) - .build(); - //stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); - when(voteOptionRepository.findById(any())).thenReturn(Optional.of(voteOption)); - when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.of(mvo)); - - //when - VoteCancleResponseDto responseDto = voteService.processVote(1L, 10L, 100L); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(option)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.empty()); - //then: dto 값 검증, memberVoteOption 삭제 호출 1회 검증 - assertThat(responseDto.getTotalVoteCount()).isEqualTo(6); - assertThat(responseDto.getVoteOptionCount()).isEqualTo(2); - assertThat(responseDto.isVoted()).isFalse(); + voteService.processVote(1L, 10L, 100L); - verify(memberVoteOptionRepository,times(1)).delete(any(MemberVoteOption.class)); + verify(pointService, never()).givePoint(any(), any()); } @Test - void 투표재선택_test(){ + @DisplayName("동일 선택지를 다시 클릭하면 투표가 취소되고 카운트가 감소한다") + void 투표취소() { + Member member = Member.builder().id(1L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(7).build(); + VoteOption voteOption = VoteOption.builder().id(100L).voteCount(3).vote(vote).build(); + MemberVoteOption mvo = MemberVoteOption.builder().voteOption(voteOption).build(); - // given - Member member = Member.builder() - .id(1L) - .build(); - Vote vote = Vote.builder() - .id(10L) - .totalVoteCount(7) - .build(); + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(voteOption)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.of(mvo)); - VoteOption voteOption = VoteOption.builder() - .id(100L) - .voteCount(3) - .vote(vote) - .build(); - VoteOption newVoteOption = VoteOption.builder() - .id(101L) - .voteCount(10) - .vote(vote) - .build(); + VoteCancleResponseDto result = voteService.processVote(1L, 10L, 100L); + + assertThat(result.getTotalVoteCount()).isEqualTo(6); + assertThat(result.getVoteOptionCount()).isEqualTo(2); + assertThat(result.isVoted()).isFalse(); + verify(memberVoteOptionRepository, times(1)).delete(any(MemberVoteOption.class)); + } + + @Test + @DisplayName("다른 선택지로 재선택하면 기존 카운트가 감소하고 새 카운트가 증가한다") + void 투표재선택() { + Member member = Member.builder().id(1L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(7).build(); + VoteOption oldOption = VoteOption.builder().id(100L).voteCount(3).vote(vote).build(); + VoteOption newOption = VoteOption.builder().id(101L).voteCount(10).vote(vote).build(); + MemberVoteOption mvo = MemberVoteOption.builder().voteOption(oldOption).build(); - MemberVoteOption mvo = MemberVoteOption.builder() - .voteOption(voteOption) - .build(); - //stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); - when(voteOptionRepository.findById(any())).thenReturn(Optional.of(newVoteOption)); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(newOption)); when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.of(mvo)); - //when - VoteCancleResponseDto responseDto = voteService.processVote(1L, 10L, 101L); - - //then: repository 동작, 응답 dto 속성 검증 - assertThat(responseDto.getTotalVoteCount()).isEqualTo(7); - assertThat(responseDto.getVoteOptionCount()).isEqualTo(11); - assertThat(responseDto.isVoted()).isTrue(); + VoteCancleResponseDto result = voteService.processVote(1L, 10L, 101L); + assertThat(result.getTotalVoteCount()).isEqualTo(7); // 총 투표 수 변동 없음 + assertThat(result.getVoteOptionCount()).isEqualTo(11); // 새 옵션 +1 + assertThat(result.isVoted()).isTrue(); + assertThat(oldOption.getVoteCount()).isEqualTo(2); // 기존 옵션 -1 verify(memberVoteOptionRepository, times(1)).delete(any(MemberVoteOption.class)); verify(memberVoteOptionRepository, times(1)).save(any(MemberVoteOption.class)); - verify(voteOptionRepository, times(2)).save(any(VoteOption.class)); } + // ────────────────────────────────────────────── + // getVoteDetailById + // ────────────────────────────────────────────── + @Test - @DisplayName("관리자의 권한으로 다른 사용자의 투표를 삭제한다.") - void 관리자권한_투표삭제_test() { - //given - Member admin = Member.builder() - .id(1L) - .role(Role.ADMIN) - .build(); + @DisplayName("존재하지 않는 투표 ID 조회 시 404 예외가 발생한다") + void 투표상세조회_없는ID() { + when(voteRepository.findById(any())).thenReturn(Optional.empty()); - Member member = Member.builder() - .id(2L) - .build(); + ApiException ex = assertThrows(ApiException.class, () -> voteService.getVoteDetailById(999L)); + assertThat(ex.getMessage()).isEqualTo("투표를 찾을 수 없습니다."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + @Test + @DisplayName("비로그인 사용자는 hasVoted=false로 투표 상세를 조회할 수 있다") + void 투표상세조회_비로그인() { + // SecurityContext를 익명 사용자로 설정 + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn("anonymousUser"); + SecurityContext ctx = mock(SecurityContext.class); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + VoteOption option = VoteOption.builder() + .id(1L).content("A선택지").voteCount(5).label(VoteLabel.A).build(); Vote vote = Vote.builder() - .member(member) - .id(10L) - .totalVoteCount(7) - .build(); + .id(10L).title("테스트 투표").totalVoteCount(10).build(); + vote.addVoteOption(option); - //stub - when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(admin)); - when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.findById(10L)).thenReturn(Optional.of(vote)); - //when - voteService.deleteVote(1L,10L); + VoteDetailResponse result = voteService.getVoteDetailById(10L); - //then - ArgumentCaptor<Vote> voteArgumentCaptor = ArgumentCaptor.forClass(Vote.class); - verify(voteRepository).save(voteArgumentCaptor.capture()); - Vote captorVote = voteArgumentCaptor.getValue(); - assertThat((captorVote.getDeletedAt())).isNotNull(); + assertThat(result.getHasVoted()).isFalse(); + assertThat(result.getVotedOptionLabel()).isNull(); + assertThat(result.getOptions()).hasSize(1); } @Test - @DisplayName("관리자가 아니면 다른 사용자의 투표를 삭제할 수 없다.") - void 관리자_투표삭제_실패test() { - //given - Member admin = Member.builder() - .id(1L) - .role(Role.USER) - .build(); + @DisplayName("로그인 사용자가 투표한 경우 hasVoted=true와 선택지 라벨을 반환한다") + void 투표상세조회_이미투표한경우() { + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn("1"); + SecurityContext ctx = mock(SecurityContext.class); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + VoteOption option = VoteOption.builder() + .id(100L).content("A선택지").voteCount(5).label(VoteLabel.A).build(); + Vote vote = Vote.builder() + .id(10L).title("테스트 투표").totalVoteCount(10).build(); + vote.addVoteOption(option); - Member member = Member.builder() - .id(2L) - .build(); + MemberVoteOption mvo = MemberVoteOption.builder().voteOption(option).build(); - Vote vote = Vote.builder() - .member(member) - .id(10L) - .totalVoteCount(7) - .build(); - // stub + when(voteRepository.findById(10L)).thenReturn(Optional.of(vote)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(1L, 10L)).thenReturn(Optional.of(mvo)); + + VoteDetailResponse result = voteService.getVoteDetailById(10L); + + assertThat(result.getHasVoted()).isTrue(); + assertThat(result.getVotedOptionLabel()).isEqualTo("A"); + } + + // ────────────────────────────────────────────── + // deleteVote + // ────────────────────────────────────────────── + + @Test + @DisplayName("본인이 만든 투표를 삭제하면 softDelete가 적용된다") + void 본인_투표삭제_성공() { + Member member = Member.builder().id(1L).role(Role.USER).build(); + Vote vote = Vote.builder().id(10L).member(member).build(); + + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + + voteService.deleteVote(1L, 10L); + + ArgumentCaptor<Vote> captor = ArgumentCaptor.forClass(Vote.class); + verify(voteRepository).save(captor.capture()); + assertThat(captor.getValue().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("관리자는 다른 사용자의 투표를 삭제할 수 있다") + void 관리자_타인투표_삭제성공() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Member writer = Member.builder().id(2L).role(Role.USER).build(); + Vote vote = Vote.builder().id(10L).member(writer).build(); + + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(admin)); + + voteService.deleteVote(1L, 10L); + + ArgumentCaptor<Vote> captor = ArgumentCaptor.forClass(Vote.class); + verify(voteRepository).save(captor.capture()); + assertThat(captor.getValue().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("일반 사용자가 다른 사람의 투표를 삭제하려 하면 403 예외가 발생한다") + void 일반사용자_타인투표_삭제실패() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + Member writer = Member.builder().id(2L).role(Role.USER).build(); + Vote vote = Vote.builder().id(10L).member(writer).build(); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(user)); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.deleteVote(1L, 10L)); + assertThat(ex.getMessage()).isEqualTo("삭제 권한이 없습니다."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + } - // when - ApiException apiException = assertThrows(ApiException.class, () -> voteService.deleteVote(1L, 10L)); + // ────────────────────────────────────────────── + // updatePinStatus + // ────────────────────────────────────────────── - // then - assertThat(apiException.getMessage()).isEqualTo("삭제 권한이 없습니다."); + @Test + @DisplayName("일반 사용자가 핀 설정을 시도하면 403 예외가 발생한다") + void 핀설정_권한없음() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + ApiException ex = assertThrows(ApiException.class, + () -> voteService.updatePinStatus(user, 10L, PinType.HOT)); + assertThat(ex.getMessage()).isEqualTo("권한이 없습니다."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); } + @Test + @DisplayName("관리자가 HOT 핀 설정 시 기존 HOT 게시물의 핀이 해제된다") + void 핀설정_기존핀해제() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Vote existingHot = Vote.builder().id(99L).build(); + existingHot.pin(PinType.HOT); + Vote newVote = Vote.builder().id(10L).build(); + when(voteRepository.findById(10L)).thenReturn(Optional.of(newVote)); + when(voteRepository.findByPinType(PinType.HOT)).thenReturn(Optional.of(existingHot)); + voteService.updatePinStatus(admin, 10L, PinType.HOT); -} \ No newline at end of file + assertThat(existingHot.getPinType()).isEqualTo(PinType.NONE); // 기존 게시물 핀 해제 + assertThat(newVote.getPinType()).isEqualTo(PinType.HOT); // 새 게시물 핀 설정 + verify(voteRepository, times(2)).save(any(Vote.class)); + } + + @Test + @DisplayName("관리자가 NONE으로 설정하면 핀이 해제된다") + void 핀해제() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Vote vote = Vote.builder().id(10L).build(); + vote.pin(PinType.HOT); + + when(voteRepository.findById(10L)).thenReturn(Optional.of(vote)); + + voteService.updatePinStatus(admin, 10L, PinType.NONE); + + assertThat(vote.getPinType()).isEqualTo(PinType.NONE); + verify(voteRepository, times(1)).save(vote); + } +}