From 33cd5d79b9159e3cbff86d8e0422669e06bc1c67 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:44:22 +0900 Subject: [PATCH 01/36] feat: add V3 vote schema migration --- .../db/migration/V3__vote_schema.sql | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/main/resources/db/migration/V3__vote_schema.sql diff --git a/src/main/resources/db/migration/V3__vote_schema.sql b/src/main/resources/db/migration/V3__vote_schema.sql new file mode 100644 index 00000000..3481a1eb --- /dev/null +++ b/src/main/resources/db/migration/V3__vote_schema.sql @@ -0,0 +1,92 @@ +-- vote 테이블 컬럼 확장 (V2에서 mock으로 만든 vote 테이블에 ALTER) +ALTER TABLE vote ADD COLUMN "type" VARCHAR(20); +ALTER TABLE vote ADD COLUMN title VARCHAR(100); +ALTER TABLE vote ADD COLUMN content VARCHAR(1000); +ALTER TABLE vote ADD COLUMN thumbnail_url VARCHAR(512); +ALTER TABLE vote ADD COLUMN image_url VARCHAR(512); +ALTER TABLE vote ADD COLUMN status VARCHAR(20); +ALTER TABLE vote ADD COLUMN end_at TIMESTAMP WITH TIME ZONE; +ALTER TABLE vote ADD COLUMN created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(); +ALTER TABLE vote ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(); +ALTER TABLE vote ADD COLUMN ai_insight_headline VARCHAR(500); +ALTER TABLE vote ADD COLUMN ai_insight_body VARCHAR(2000); + +-- mock에 있던 row가 있으면 NOT NULL 제약 전 더미 채움 (운영 데이터 없음 가정) +UPDATE vote SET + "type" = 'GENERAL', + title = 'migrated', + thumbnail_url = '', + status = 'ENDED', + end_at = now() +WHERE "type" IS NULL; + +ALTER TABLE vote ALTER COLUMN "type" SET NOT NULL; +ALTER TABLE vote ALTER COLUMN title SET NOT NULL; +ALTER TABLE vote ALTER COLUMN thumbnail_url SET NOT NULL; +ALTER TABLE vote ALTER COLUMN status SET NOT NULL; +ALTER TABLE vote ALTER COLUMN end_at SET NOT NULL; + +CREATE INDEX idx_vote_type_end_at ON vote ("type", end_at DESC); +CREATE INDEX idx_vote_status_end_at ON vote (status, end_at DESC); + +-- vote_option 신규 +CREATE TABLE vote_option ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + vote_id BIGINT NOT NULL REFERENCES vote(id), + label VARCHAR(50) NOT NULL, + position INT NOT NULL +); +CREATE INDEX idx_vote_option_vote_id ON vote_option (vote_id, position); + +-- vote_participation 컬럼 확장 +ALTER TABLE vote_participation ADD COLUMN anonymous_id VARCHAR(36); +ALTER TABLE vote_participation ADD COLUMN option_id BIGINT; +ALTER TABLE vote_participation ADD COLUMN created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(); +ALTER TABLE vote_participation ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(); +ALTER TABLE vote_participation ALTER COLUMN user_id DROP NOT NULL; + +-- mock 데이터 정리 (운영 데이터 없음 가정) +DELETE FROM vote_participation WHERE option_id IS NULL; + +ALTER TABLE vote_participation ALTER COLUMN option_id SET NOT NULL; +ALTER TABLE vote_participation ADD CONSTRAINT chk_voter CHECK ( + (user_id IS NOT NULL AND anonymous_id IS NULL) OR + (user_id IS NULL AND anonymous_id IS NOT NULL) +); +ALTER TABLE vote_participation ADD CONSTRAINT fk_vote_participation_option + FOREIGN KEY (option_id) REFERENCES vote_option(id); + +-- 기존 (vote_id, user_id) UNIQUE는 유지. anonymous_id UNIQUE 추가 +ALTER TABLE vote_participation ADD CONSTRAINT uq_guest_vote UNIQUE (vote_id, anonymous_id); + +CREATE INDEX idx_vp_anonymous_id ON vote_participation (anonymous_id); + +-- vote_emoji_reaction 신규 +CREATE TABLE vote_emoji_reaction ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + vote_id BIGINT NOT NULL REFERENCES vote(id), + user_id BIGINT REFERENCES users(id), + anonymous_id VARCHAR(36), + emoji VARCHAR(20) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT chk_reactor CHECK ( + (user_id IS NOT NULL AND anonymous_id IS NULL) OR + (user_id IS NULL AND anonymous_id IS NOT NULL) + ), + CONSTRAINT uq_member_emoji UNIQUE (vote_id, user_id), + CONSTRAINT uq_guest_emoji UNIQUE (vote_id, anonymous_id) +); + +-- guest_free_vote 신규 +CREATE TABLE guest_free_vote ( + anonymous_id VARCHAR(36) PRIMARY KEY, + consumed_count INT NOT NULL DEFAULT 0, + last_consumed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- users 테이블에 분석용 컬럼 추가 (Insight 분석) +ALTER TABLE users ADD COLUMN gender VARCHAR(10); +ALTER TABLE users ADD COLUMN birth_date DATE; From 0ed152906f8740c44d9d9a16cce7554e5cd73e27 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:44:32 +0900 Subject: [PATCH 02/36] feat: add vote domain enums and value types --- .../com/ject/vs/vote/domain/AgeGroup.java | 28 +++++++++++++++++++ .../vs/vote/domain/ImmersiveVoteAction.java | 5 ++++ .../com/ject/vs/vote/domain/InsightScope.java | 5 ++++ .../com/ject/vs/vote/domain/VoteDuration.java | 24 ++++++++++++++++ .../com/ject/vs/vote/domain/VoteEmoji.java | 5 ++++ .../com/ject/vs/vote/domain/VoteStatus.java | 5 ++++ .../com/ject/vs/vote/domain/VoteType.java | 5 ++++ 7 files changed, 77 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/domain/AgeGroup.java create mode 100644 src/main/java/com/ject/vs/vote/domain/ImmersiveVoteAction.java create mode 100644 src/main/java/com/ject/vs/vote/domain/InsightScope.java create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteDuration.java create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteEmoji.java create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteStatus.java create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteType.java diff --git a/src/main/java/com/ject/vs/vote/domain/AgeGroup.java b/src/main/java/com/ject/vs/vote/domain/AgeGroup.java new file mode 100644 index 00000000..0cbc84bd --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/AgeGroup.java @@ -0,0 +1,28 @@ +package com.ject.vs.vote.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Clock; +import java.time.LocalDate; + +@Getter +@RequiredArgsConstructor +public enum AgeGroup { + TEENS("10s"), + TWENTIES("20s"), + THIRTIES("30s"), + FORTIES("40s"), + FIFTIES_PLUS("50s_PLUS"); + + private final String label; + + public static AgeGroup fromBirthDate(LocalDate birthDate, Clock clock) { + int age = LocalDate.now(clock).getYear() - birthDate.getYear(); + if (age < 20) return TEENS; + if (age < 30) return TWENTIES; + if (age < 40) return THIRTIES; + if (age < 50) return FORTIES; + return FIFTIES_PLUS; + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/ImmersiveVoteAction.java b/src/main/java/com/ject/vs/vote/domain/ImmersiveVoteAction.java new file mode 100644 index 00000000..0e06af88 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/ImmersiveVoteAction.java @@ -0,0 +1,5 @@ +package com.ject.vs.vote.domain; + +public enum ImmersiveVoteAction { + VOTED, CANCELED +} diff --git a/src/main/java/com/ject/vs/vote/domain/InsightScope.java b/src/main/java/com/ject/vs/vote/domain/InsightScope.java new file mode 100644 index 00000000..08278a9c --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/InsightScope.java @@ -0,0 +1,5 @@ +package com.ject.vs.vote.domain; + +public enum InsightScope { + MY_SELECTION, TOTAL +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteDuration.java b/src/main/java/com/ject/vs/vote/domain/VoteDuration.java new file mode 100644 index 00000000..7a18f6d6 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteDuration.java @@ -0,0 +1,24 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.vote.exception.InvalidDurationException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Duration; +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum VoteDuration { + HOURS_12(Duration.ofHours(12)), + HOURS_24(Duration.ofHours(24)); + + private final Duration value; + + public static VoteDuration fromHours(int hours) { + return Arrays.stream(values()) + .filter(d -> d.value.toHours() == hours) + .findFirst() + .orElseThrow(() -> new InvalidDurationException(hours)); + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java b/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java new file mode 100644 index 00000000..a9c7205f --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java @@ -0,0 +1,5 @@ +package com.ject.vs.vote.domain; + +public enum VoteEmoji { + LIKE, SAD, ANGRY, WOW +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteStatus.java b/src/main/java/com/ject/vs/vote/domain/VoteStatus.java new file mode 100644 index 00000000..f93d6203 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteStatus.java @@ -0,0 +1,5 @@ +package com.ject.vs.vote.domain; + +public enum VoteStatus { + ONGOING, ENDED +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteType.java b/src/main/java/com/ject/vs/vote/domain/VoteType.java new file mode 100644 index 00000000..758af5c1 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteType.java @@ -0,0 +1,5 @@ +package com.ject.vs.vote.domain; + +public enum VoteType { + GENERAL, IMMERSIVE +} From aaaf4f488adc0825941d2de8d1ab166253053a5c Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:44:42 +0900 Subject: [PATCH 03/36] feat: implement Vote, VoteParticipation, VoteOption entities and repositories --- .../java/com/ject/vs/vote/domain/Vote.java | 79 +++++++++++++++++-- .../com/ject/vs/vote/domain/VoteOption.java | 34 ++++++++ .../vs/vote/domain/VoteOptionRepository.java | 12 +++ .../vs/vote/domain/VoteParticipation.java | 42 +++++++--- .../domain/VoteParticipationRepository.java | 14 +++- .../ject/vs/vote/domain/VoteRepository.java | 16 ++++ 6 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteOption.java create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteOptionRepository.java diff --git a/src/main/java/com/ject/vs/vote/domain/Vote.java b/src/main/java/com/ject/vs/vote/domain/Vote.java index eaf4327c..3c03ad05 100644 --- a/src/main/java/com/ject/vs/vote/domain/Vote.java +++ b/src/main/java/com/ject/vs/vote/domain/Vote.java @@ -1,19 +1,84 @@ package com.ject.vs.vote.domain; -import com.ject.vs.common.domain.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; +import com.ject.vs.common.domain.BaseTimeEntity; +import com.ject.vs.vote.exception.ImageRequiredException; +import jakarta.persistence.*; +import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; + import static lombok.AccessLevel.PROTECTED; @Entity @Table(name = "vote") +@Getter @NoArgsConstructor(access = PROTECTED) -public class Vote extends BaseEntity { +public class Vote extends BaseTimeEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private VoteType type; + + @Column(nullable = false) + private String title; + + private String content; + + @Column(nullable = false) + private String thumbnailUrl; + + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private VoteStatus status; + + @Column(nullable = false) + private Instant endAt; + + private String aiInsightHeadline; + private String aiInsightBody; + + public static Vote create(VoteType type, String title, String content, + String thumbnailUrl, String imageUrl, + Duration validityPeriod, Clock clock) { + if (type == VoteType.IMMERSIVE && (imageUrl == null || imageUrl.isBlank())) { + throw new ImageRequiredException(); + } + Vote vote = new Vote(); + vote.type = type; + vote.title = title; + vote.content = content; + vote.thumbnailUrl = thumbnailUrl; + vote.imageUrl = imageUrl; + vote.status = VoteStatus.ONGOING; + vote.endAt = Instant.now(clock).plus(validityPeriod); + return vote; + } + + /** 진실의 원천: endAt만 본다. status 컬럼은 보지 않는다. */ + public boolean isOngoing(Clock clock) { + return Instant.now(clock).isBefore(endAt); + } + + public boolean isEnded(Clock clock) { + return !isOngoing(clock); + } + + /** 스케줄러용 — status 컬럼을 ENDED로 마킹 (캐시 갱신) */ + public void markEnded() { + this.status = VoteStatus.ENDED; + } + + public void cacheAiInsight(String headline, String body) { + this.aiInsightHeadline = headline; + this.aiInsightBody = body; + } - // Vote 상세 필드(title, status 등)는 Vote 담당자가 추가 예정. 현재는 id만 관리. - public static Vote of() { - return new Vote(); + public boolean hasAiInsight() { + return aiInsightHeadline != null && aiInsightBody != null; } } diff --git a/src/main/java/com/ject/vs/vote/domain/VoteOption.java b/src/main/java/com/ject/vs/vote/domain/VoteOption.java new file mode 100644 index 00000000..4228a5cc --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteOption.java @@ -0,0 +1,34 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.common.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table(name = "vote_option") +@Getter +@NoArgsConstructor(access = PROTECTED) +public class VoteOption extends BaseEntity { + + @Column(nullable = false) + private Long voteId; + + @Column(nullable = false) + private String label; + + @Column(nullable = false) + private int position; + + public static VoteOption of(Long voteId, String label, int position) { + VoteOption option = new VoteOption(); + option.voteId = voteId; + option.label = label; + option.position = position; + return option; + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteOptionRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteOptionRepository.java new file mode 100644 index 00000000..038bfef8 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteOptionRepository.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface VoteOptionRepository extends JpaRepository { + + List findByVoteIdOrderByPosition(Long voteId); + + boolean existsByIdAndVoteId(Long id, Long voteId); +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteParticipation.java b/src/main/java/com/ject/vs/vote/domain/VoteParticipation.java index 3f2508bd..daecd7e8 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteParticipation.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteParticipation.java @@ -1,6 +1,6 @@ package com.ject.vs.vote.domain; -import com.ject.vs.common.domain.BaseEntity; +import com.ject.vs.common.domain.BaseTimeEntity; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,22 +10,46 @@ @Entity @Table( name = "vote_participation", - uniqueConstraints = @UniqueConstraint(columnNames = {"vote_id", "user_id"}) + uniqueConstraints = { + @UniqueConstraint(columnNames = {"vote_id", "user_id"}), + @UniqueConstraint(name = "uq_guest_vote", columnNames = {"vote_id", "anonymous_id"}) + } ) @Getter @NoArgsConstructor(access = PROTECTED) -public class VoteParticipation extends BaseEntity { +public class VoteParticipation extends BaseTimeEntity { @Column(nullable = false) private Long voteId; - @Column(nullable = false) private Long userId; - public static VoteParticipation of(Long voteId, Long userId) { - VoteParticipation voteParticipation = new VoteParticipation(); - voteParticipation.voteId = voteId; - voteParticipation.userId = userId; - return voteParticipation; + private String anonymousId; + + @Column(nullable = false) + private Long optionId; + + public static VoteParticipation ofMember(Long voteId, Long userId, Long optionId) { + VoteParticipation p = new VoteParticipation(); + p.voteId = voteId; + p.userId = userId; + p.optionId = optionId; + return p; + } + + public static VoteParticipation ofGuest(Long voteId, String anonymousId, Long optionId) { + VoteParticipation p = new VoteParticipation(); + p.voteId = voteId; + p.anonymousId = anonymousId; + p.optionId = optionId; + return p; + } + + public boolean isGuest() { + return anonymousId != null; + } + + public void changeOption(Long optionId) { + this.optionId = optionId; } } diff --git a/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java index f658991a..8c266661 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java @@ -5,14 +5,26 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface VoteParticipationRepository extends JpaRepository { + + // 기존 시그니처 유지 (채팅 도메인 및 레거시 호환) boolean existsByVoteIdAndUserId(Long voteId, Long userId); + long countByVoteId(Long voteId); @Query("SELECT p.voteId FROM VoteParticipation p WHERE p.userId = :userId") List findAllVoteIdsByUserId(@Param("userId") Long userId); - @Query("SELECT p.userId FROM VoteParticipation p WHERE p.voteId = :voteId") + @Query("SELECT p.userId FROM VoteParticipation p WHERE p.voteId = :voteId AND p.userId IS NOT NULL") List findAllUserIdsByVoteId(@Param("voteId") Long voteId); + + Optional findByVoteIdAndUserId(Long voteId, Long userId); + + Optional findByVoteIdAndAnonymousId(Long voteId, String anonymousId); + + long countByVoteIdAndOptionId(Long voteId, Long optionId); + + void deleteByVoteIdAndUserId(Long voteId, Long userId); } diff --git a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java index b2f5789a..6ab762b6 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java @@ -1,6 +1,22 @@ package com.ject.vs.vote.domain; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; public interface VoteRepository extends JpaRepository { + + @Query("SELECT v FROM Vote v WHERE v.status = com.ject.vs.vote.domain.VoteStatus.ONGOING AND v.endAt < :now") + List findExpiredOngoing(@Param("now") Instant now); + + List findAllByIdIn(List ids); + + Slice findByTypeOrderByEndAtDesc(VoteType type, Pageable pageable); + + Slice findByTypeAndIdLessThanOrderByEndAtDesc(VoteType type, Long cursor, Pageable pageable); } From 7e477f29558efce5a466c1e19b8f8466e4623159 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:44:45 +0900 Subject: [PATCH 04/36] feat: add vote domain exception classes --- .../vs/vote/exception/ImageRequiredException.java | 12 ++++++++++++ .../vs/vote/exception/InvalidDurationException.java | 12 ++++++++++++ .../vs/vote/exception/InvalidEmojiException.java | 12 ++++++++++++ .../vs/vote/exception/InvalidOptionException.java | 12 ++++++++++++ .../ject/vs/vote/exception/VoteEndedException.java | 12 ++++++++++++ .../exception/VoteFreeLimitExceededException.java | 12 ++++++++++++ .../vs/vote/exception/VoteNotEndedException.java | 12 ++++++++++++ .../vs/vote/exception/VoteNotFoundException.java | 12 ++++++++++++ 8 files changed, 96 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/VoteEndedException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java create mode 100644 src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java diff --git a/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java b/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java new file mode 100644 index 00000000..1435ec8f --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class ImageRequiredException extends RuntimeException { + + public ImageRequiredException() { + super("몰입형 투표에는 이미지가 필요합니다."); + } + + public String getErrorCode() { + return "IMAGE_REQUIRED"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java b/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java new file mode 100644 index 00000000..a7fc6c2d --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class InvalidDurationException extends RuntimeException { + + public InvalidDurationException(int hours) { + super("유효하지 않은 투표 기간입니다: " + hours + "시간"); + } + + public String getErrorCode() { + return "INVALID_DURATION"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java b/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java new file mode 100644 index 00000000..235b7361 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class InvalidEmojiException extends RuntimeException { + + public InvalidEmojiException() { + super("유효하지 않은 이모지입니다."); + } + + public String getErrorCode() { + return "INVALID_EMOJI"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java b/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java new file mode 100644 index 00000000..3b28be80 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class InvalidOptionException extends RuntimeException { + + public InvalidOptionException() { + super("유효하지 않은 투표 선택지입니다."); + } + + public String getErrorCode() { + return "INVALID_OPTION"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java b/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java new file mode 100644 index 00000000..5b95ac17 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class VoteEndedException extends RuntimeException { + + public VoteEndedException() { + super("이미 종료된 투표입니다."); + } + + public String getErrorCode() { + return "VOTE_ENDED"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java b/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java new file mode 100644 index 00000000..963aae67 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class VoteFreeLimitExceededException extends RuntimeException { + + public VoteFreeLimitExceededException() { + super("무료 투표 횟수를 초과했습니다."); + } + + public String getErrorCode() { + return "VOTE_FREE_LIMIT_EXCEEDED"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java b/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java new file mode 100644 index 00000000..6289105b --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class VoteNotEndedException extends RuntimeException { + + public VoteNotEndedException() { + super("아직 진행 중인 투표입니다."); + } + + public String getErrorCode() { + return "VOTE_NOT_ENDED"; + } +} diff --git a/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java b/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java new file mode 100644 index 00000000..aa304b66 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java @@ -0,0 +1,12 @@ +package com.ject.vs.vote.exception; + +public class VoteNotFoundException extends RuntimeException { + + public VoteNotFoundException() { + super("투표를 찾을 수 없습니다."); + } + + public String getErrorCode() { + return "VOTE_NOT_FOUND"; + } +} From 16d87c7c1ed72c75488f5e7ddd10e6f0f080f72e Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:44:54 +0900 Subject: [PATCH 05/36] feat: replace VoteService with VoteQueryService and define VoteQueryUseCase --- .../java/com/ject/vs/config/ClockConfig.java | 15 +++ .../ject/vs/vote/port/VoteQueryService.java | 91 +++++++++++++++++++ .../com/ject/vs/vote/port/VoteService.java | 45 --------- .../vs/vote/port/in/VoteQueryUseCase.java | 23 ++++- .../ject/vs/vote/port/in/dto/VoteStatus.java | 5 - 5 files changed, 127 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/ject/vs/config/ClockConfig.java create mode 100644 src/main/java/com/ject/vs/vote/port/VoteQueryService.java delete mode 100644 src/main/java/com/ject/vs/vote/port/VoteService.java delete mode 100644 src/main/java/com/ject/vs/vote/port/in/dto/VoteStatus.java diff --git a/src/main/java/com/ject/vs/config/ClockConfig.java b/src/main/java/com/ject/vs/config/ClockConfig.java new file mode 100644 index 00000000..08414ad1 --- /dev/null +++ b/src/main/java/com/ject/vs/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.ject.vs.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock systemClock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/ject/vs/vote/port/VoteQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteQueryService.java new file mode 100644 index 00000000..4fa8b088 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/VoteQueryService.java @@ -0,0 +1,91 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.VoteParticipationQueryUseCase; +import com.ject.vs.vote.port.in.VoteQueryUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoteQueryService implements VoteQueryUseCase, VoteParticipationQueryUseCase { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final Clock clock; + + @Override + public boolean isParticipated(Long voteId, Long userId) { + return voteParticipationRepository.existsByVoteIdAndUserId(voteId, userId); + } + + @Override + public Optional getSelectedOptionId(Long voteId, Long userId) { + return voteParticipationRepository.findByVoteIdAndUserId(voteId, userId) + .map(VoteParticipation::getOptionId); + } + + @Override + public VoteSummary getVoteSummary(Long voteId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + VoteStatus computedStatus = vote.isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; + return new VoteSummary(vote.getId(), vote.getTitle(), computedStatus, vote.getEndAt()); + } + + @Override + public VoteRatio getRatio(Long voteId) { + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + if (options.size() != 2) throw new IllegalStateException("Vote must have exactly 2 options"); + long total = voteParticipationRepository.countByVoteId(voteId); + long aCount = voteParticipationRepository.countByVoteIdAndOptionId(voteId, options.get(0).getId()); + int aRatio = total == 0 ? 0 : (int) Math.round(aCount * 100.0 / total); + return new VoteRatio(aRatio, 100 - aRatio, (int) total); + } + + @Override + public int getParticipantCount(Long voteId) { + return (int) voteParticipationRepository.countByVoteId(voteId); + } + + @Override + public List findAllVoteIdsByStatus(List voteIds, VoteStatus status) { + if (voteIds.isEmpty()) return List.of(); + return voteRepository.findAllByIdIn(voteIds).stream() + .filter(vote -> { + boolean ongoing = vote.isOngoing(clock); + return status == VoteStatus.ONGOING ? ongoing : !ongoing; + }) + .map(Vote::getId) + .toList(); + } + + // VoteParticipationQueryUseCase 구현 (채팅 도메인 호환) + + @Override + public boolean isParticipant(Long voteId, Long userId) { + return isParticipated(voteId, userId); + } + + @Override + public List findAllVoteIdsByUserId(Long userId) { + return voteParticipationRepository.findAllVoteIdsByUserId(userId); + } + + @Override + public long countParticipantsByVoteId(Long voteId) { + return voteParticipationRepository.countByVoteId(voteId); + } + + @Override + public List findAllUserIdsByVoteId(Long voteId) { + return voteParticipationRepository.findAllUserIdsByVoteId(voteId); + } +} diff --git a/src/main/java/com/ject/vs/vote/port/VoteService.java b/src/main/java/com/ject/vs/vote/port/VoteService.java deleted file mode 100644 index e68adba0..00000000 --- a/src/main/java/com/ject/vs/vote/port/VoteService.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.ject.vs.vote.port; - -import com.ject.vs.vote.domain.VoteParticipationRepository; -import com.ject.vs.vote.port.in.VoteParticipationQueryUseCase; -import com.ject.vs.vote.port.in.VoteQueryUseCase; -import com.ject.vs.vote.port.in.dto.VoteStatus; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class VoteService implements VoteParticipationQueryUseCase, VoteQueryUseCase { - - private final VoteParticipationRepository voteParticipationRepository; - - @Override - public boolean isParticipant(Long voteId, Long userId) { - return voteParticipationRepository.existsByVoteIdAndUserId(voteId, userId); - } - - @Override - public List findAllVoteIdsByUserId(Long userId) { - return voteParticipationRepository.findAllVoteIdsByUserId(userId); - } - - @Override - public long countParticipantsByVoteId(Long voteId) { - return voteParticipationRepository.countByVoteId(voteId); - } - - @Override - public List findAllUserIdsByVoteId(Long voteId) { - return voteParticipationRepository.findAllUserIdsByVoteId(voteId); - } - - @Override - public List findAllVoteIdsByStatus(List voteIds, VoteStatus status) { - // TODO: Vote 도메인 연동 후 실제 status 기반 필터링으로 교체 - return voteIds; - } -} diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java index 7566b74b..aacf7c07 100644 --- a/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java +++ b/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java @@ -1,10 +1,29 @@ package com.ject.vs.vote.port.in; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; +import java.time.Instant; import java.util.List; +import java.util.Optional; public interface VoteQueryUseCase { - // TODO: Vote 도메인 연동 후 실제 status 기반 필터링으로 교체 + + boolean isParticipated(Long voteId, Long userId); + + Optional getSelectedOptionId(Long voteId, Long userId); + + VoteSummary getVoteSummary(Long voteId); + + VoteRatio getRatio(Long voteId); + + int getParticipantCount(Long voteId); + + /** 채팅 도메인 getChatList() 호환용 — 실제 Vote.endAt 기준으로 필터링 */ List findAllVoteIdsByStatus(List voteIds, VoteStatus status); + + record VoteSummary(Long voteId, String title, VoteStatus status, Instant endAt) { + } + + record VoteRatio(int optionARatio, int optionBRatio, int participantCount) { + } } diff --git a/src/main/java/com/ject/vs/vote/port/in/dto/VoteStatus.java b/src/main/java/com/ject/vs/vote/port/in/dto/VoteStatus.java deleted file mode 100644 index 0d10005b..00000000 --- a/src/main/java/com/ject/vs/vote/port/in/dto/VoteStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.ject.vs.vote.port.in.dto; - -public enum VoteStatus { - ONGOING, ENDED -} From 4b7b777bf2602d3cba9448cc72cd1fcd71e89376 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:45:00 +0900 Subject: [PATCH 06/36] refactor: update chat bounded context to use VoteStatus from domain package --- src/main/java/com/ject/vs/chat/adapter/web/ChatController.java | 2 +- src/main/java/com/ject/vs/chat/adapter/web/ChatDocs.java | 2 +- .../java/com/ject/vs/chat/adapter/web/dto/ChatRoomResponse.java | 2 +- src/main/java/com/ject/vs/chat/port/ChatService.java | 2 +- src/main/java/com/ject/vs/chat/port/in/ChatQueryUseCase.java | 2 +- src/main/java/com/ject/vs/chat/port/in/dto/ChatRoomResult.java | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java b/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java index 2bc0799f..bc89169b 100644 --- a/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java +++ b/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java @@ -3,7 +3,7 @@ import com.ject.vs.chat.adapter.web.dto.*; import com.ject.vs.chat.port.in.*; import com.ject.vs.chat.port.in.dto.*; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/ject/vs/chat/adapter/web/ChatDocs.java b/src/main/java/com/ject/vs/chat/adapter/web/ChatDocs.java index a41aca3e..b1a7d26b 100644 --- a/src/main/java/com/ject/vs/chat/adapter/web/ChatDocs.java +++ b/src/main/java/com/ject/vs/chat/adapter/web/ChatDocs.java @@ -7,7 +7,7 @@ import com.ject.vs.chat.adapter.web.dto.MessagePageResponse; import com.ject.vs.chat.adapter.web.dto.MessageResponse; import com.ject.vs.chat.adapter.web.dto.SendMessageRequest; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/com/ject/vs/chat/adapter/web/dto/ChatRoomResponse.java b/src/main/java/com/ject/vs/chat/adapter/web/dto/ChatRoomResponse.java index 09358bf9..ad641eee 100644 --- a/src/main/java/com/ject/vs/chat/adapter/web/dto/ChatRoomResponse.java +++ b/src/main/java/com/ject/vs/chat/adapter/web/dto/ChatRoomResponse.java @@ -1,7 +1,7 @@ package com.ject.vs.chat.adapter.web.dto; import com.ject.vs.chat.port.in.dto.ChatRoomResult; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; diff --git a/src/main/java/com/ject/vs/chat/port/ChatService.java b/src/main/java/com/ject/vs/chat/port/ChatService.java index b8d8f760..00f45587 100644 --- a/src/main/java/com/ject/vs/chat/port/ChatService.java +++ b/src/main/java/com/ject/vs/chat/port/ChatService.java @@ -11,7 +11,7 @@ import com.ject.vs.chat.port.in.dto.*; import com.ject.vs.vote.port.in.VoteParticipationQueryUseCase; import com.ject.vs.vote.port.in.VoteQueryUseCase; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/ject/vs/chat/port/in/ChatQueryUseCase.java b/src/main/java/com/ject/vs/chat/port/in/ChatQueryUseCase.java index 6835fa94..6f231ff2 100644 --- a/src/main/java/com/ject/vs/chat/port/in/ChatQueryUseCase.java +++ b/src/main/java/com/ject/vs/chat/port/in/ChatQueryUseCase.java @@ -4,7 +4,7 @@ import com.ject.vs.chat.port.in.dto.ChatRoomResult; import com.ject.vs.chat.port.in.dto.GaugeResult; import com.ject.vs.chat.port.in.dto.MessagePageResult; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import java.util.List; diff --git a/src/main/java/com/ject/vs/chat/port/in/dto/ChatRoomResult.java b/src/main/java/com/ject/vs/chat/port/in/dto/ChatRoomResult.java index 9d7b7ffa..ad1c793a 100644 --- a/src/main/java/com/ject/vs/chat/port/in/dto/ChatRoomResult.java +++ b/src/main/java/com/ject/vs/chat/port/in/dto/ChatRoomResult.java @@ -1,6 +1,6 @@ package com.ject.vs.chat.port.in.dto; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import java.time.Instant; From 04a5244098ae4200e37b429d8c1c5662ada469cc Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 14:45:11 +0900 Subject: [PATCH 07/36] test: add vote domain tests and fix DataJpaTest configuration --- .../chat/adapter/web/ChatControllerTest.java | 2 +- .../domain/ChatMessageRepositoryTest.java | 18 +- .../domain/ChatRoomUnreadRepositoryTest.java | 18 +- .../ject/vs/vote/domain/VoteDurationTest.java | 32 ++++ .../VoteParticipationRepositoryTest.java | 91 +++++++-- .../vs/vote/domain/VoteParticipationTest.java | 57 +++++- .../com/ject/vs/vote/domain/VoteTest.java | 139 ++++++++++++++ .../vs/vote/port/VoteQueryServiceTest.java | 178 ++++++++++++++++++ .../ject/vs/vote/port/VoteServiceTest.java | 144 -------------- src/test/resources/application-test.yml | 2 +- 10 files changed, 511 insertions(+), 170 deletions(-) create mode 100644 src/test/java/com/ject/vs/vote/domain/VoteDurationTest.java create mode 100644 src/test/java/com/ject/vs/vote/domain/VoteTest.java create mode 100644 src/test/java/com/ject/vs/vote/port/VoteQueryServiceTest.java delete mode 100644 src/test/java/com/ject/vs/vote/port/VoteServiceTest.java diff --git a/src/test/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java b/src/test/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java index 46418c0b..ae2447d1 100644 --- a/src/test/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java +++ b/src/test/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java @@ -6,7 +6,7 @@ import com.ject.vs.chat.exception.ChatForbiddenException; import com.ject.vs.chat.port.in.*; import com.ject.vs.chat.port.in.dto.*; -import com.ject.vs.vote.port.in.dto.VoteStatus; +import com.ject.vs.vote.domain.VoteStatus; import com.ject.vs.util.CookieUtil; import com.ject.vs.util.JwtProvider; import org.junit.jupiter.api.Nested; diff --git a/src/test/java/com/ject/vs/chat/domain/ChatMessageRepositoryTest.java b/src/test/java/com/ject/vs/chat/domain/ChatMessageRepositoryTest.java index 8ed36c1c..9a350475 100644 --- a/src/test/java/com/ject/vs/chat/domain/ChatMessageRepositoryTest.java +++ b/src/test/java/com/ject/vs/chat/domain/ChatMessageRepositoryTest.java @@ -1,20 +1,32 @@ package com.ject.vs.chat.domain; +import com.ject.vs.config.JpaAuditingConfig; import com.ject.vs.domain.User; import com.ject.vs.vote.domain.Vote; +import com.ject.vs.vote.domain.VoteType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaAuditingConfig.class) class ChatMessageRepositoryTest { @Autowired @@ -26,10 +38,14 @@ class ChatMessageRepositoryTest { private Long voteId; private Long userId; + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + @BeforeEach void setUp() { User user = entityManager.persistAndFlush(User.createWithSub("test-sub")); - Vote vote = entityManager.persistAndFlush(Vote.of()); + Vote vote = entityManager.persistAndFlush( + Vote.create(VoteType.GENERAL, "테스트", null, "thumb", null, Duration.ofHours(24), FIXED_CLOCK) + ); voteId = vote.getId(); userId = user.getId(); } diff --git a/src/test/java/com/ject/vs/chat/domain/ChatRoomUnreadRepositoryTest.java b/src/test/java/com/ject/vs/chat/domain/ChatRoomUnreadRepositoryTest.java index ea288399..7781e11c 100644 --- a/src/test/java/com/ject/vs/chat/domain/ChatRoomUnreadRepositoryTest.java +++ b/src/test/java/com/ject/vs/chat/domain/ChatRoomUnreadRepositoryTest.java @@ -1,19 +1,31 @@ package com.ject.vs.chat.domain; +import com.ject.vs.config.JpaAuditingConfig; import com.ject.vs.domain.User; import com.ject.vs.vote.domain.Vote; +import com.ject.vs.vote.domain.VoteType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaAuditingConfig.class) class ChatRoomUnreadRepositoryTest { @Autowired @@ -25,10 +37,14 @@ class ChatRoomUnreadRepositoryTest { private Long userId; private Long voteId; + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + @BeforeEach void setUp() { User user = entityManager.persistAndFlush(User.createWithSub("test-sub")); - Vote vote = entityManager.persistAndFlush(Vote.of()); + Vote vote = entityManager.persistAndFlush( + Vote.create(VoteType.GENERAL, "테스트", null, "thumb", null, Duration.ofHours(24), FIXED_CLOCK) + ); userId = user.getId(); voteId = vote.getId(); } diff --git a/src/test/java/com/ject/vs/vote/domain/VoteDurationTest.java b/src/test/java/com/ject/vs/vote/domain/VoteDurationTest.java new file mode 100644 index 00000000..59ba1694 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/domain/VoteDurationTest.java @@ -0,0 +1,32 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.vote.exception.InvalidDurationException; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class VoteDurationTest { + + @Test + void fromHours_12() { + VoteDuration duration = VoteDuration.fromHours(12); + assertThat(duration).isEqualTo(VoteDuration.HOURS_12); + assertThat(duration.getValue()).isEqualTo(Duration.ofHours(12)); + } + + @Test + void fromHours_24() { + VoteDuration duration = VoteDuration.fromHours(24); + assertThat(duration).isEqualTo(VoteDuration.HOURS_24); + assertThat(duration.getValue()).isEqualTo(Duration.ofHours(24)); + } + + @Test + void 유효하지_않은_시간은_InvalidDurationException() { + assertThatThrownBy(() -> VoteDuration.fromHours(13)) + .isInstanceOf(InvalidDurationException.class); + } +} diff --git a/src/test/java/com/ject/vs/vote/domain/VoteParticipationRepositoryTest.java b/src/test/java/com/ject/vs/vote/domain/VoteParticipationRepositoryTest.java index ce442cd8..2bb78958 100644 --- a/src/test/java/com/ject/vs/vote/domain/VoteParticipationRepositoryTest.java +++ b/src/test/java/com/ject/vs/vote/domain/VoteParticipationRepositoryTest.java @@ -1,53 +1,120 @@ package com.ject.vs.vote.domain; +import com.ject.vs.config.JpaAuditingConfig; import com.ject.vs.domain.User; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaAuditingConfig.class) class VoteParticipationRepositoryTest { + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + @Autowired private TestEntityManager entityManager; @Autowired private VoteRepository voteRepository; + @Autowired + private VoteOptionRepository voteOptionRepository; + @Autowired private VoteParticipationRepository voteParticipationRepository; + private Vote vote; + private VoteOption optionA; + + @BeforeEach + void setUp() { + vote = voteRepository.save( + Vote.create(VoteType.GENERAL, "테스트 투표", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK) + ); + optionA = voteOptionRepository.save(VoteOption.of(vote.getId(), "A", 0)); + voteOptionRepository.save(VoteOption.of(vote.getId(), "B", 1)); + } + @Nested class existsByVoteIdAndUserId { @Test - void 저장된_참여자는_true를_반환한다() { - // given + void 저장된_회원_참여자는_true를_반환한다() { User user = entityManager.persistAndFlush(User.createWithSub("test-sub")); - Vote vote = voteRepository.save(Vote.of()); - voteParticipationRepository.save(VoteParticipation.of(vote.getId(), user.getId())); + voteParticipationRepository.save( + VoteParticipation.ofMember(vote.getId(), user.getId(), optionA.getId()) + ); - // when boolean result = voteParticipationRepository.existsByVoteIdAndUserId(vote.getId(), user.getId()); - // then assertThat(result).isTrue(); } @Test void 존재하지_않는_경우_false를_반환한다() { - // given - // (no data) - - // when boolean result = voteParticipationRepository.existsByVoteIdAndUserId(999L, 999L); - - // then assertThat(result).isFalse(); } } + + @Nested + class 회원_비회원_분기 { + + @Test + void 회원_참여_저장_및_조회() { + User user = entityManager.persistAndFlush(User.createWithSub("member-sub")); + voteParticipationRepository.save( + VoteParticipation.ofMember(vote.getId(), user.getId(), optionA.getId()) + ); + + var found = voteParticipationRepository.findByVoteIdAndUserId(vote.getId(), user.getId()); + assertThat(found).isPresent(); + assertThat(found.get().isGuest()).isFalse(); + } + + @Test + void 비회원_참여_저장_및_조회() { + String anonId = "anon-uuid-1234"; + voteParticipationRepository.save( + VoteParticipation.ofGuest(vote.getId(), anonId, optionA.getId()) + ); + + var found = voteParticipationRepository.findByVoteIdAndAnonymousId(vote.getId(), anonId); + assertThat(found).isPresent(); + assertThat(found.get().isGuest()).isTrue(); + } + } + + @Nested + class countByVoteId { + + @Test + void 참여자_수를_올바르게_반환한다() { + User user1 = entityManager.persistAndFlush(User.createWithSub("sub-1")); + User user2 = entityManager.persistAndFlush(User.createWithSub("sub-2")); + voteParticipationRepository.save(VoteParticipation.ofMember(vote.getId(), user1.getId(), optionA.getId())); + voteParticipationRepository.save(VoteParticipation.ofMember(vote.getId(), user2.getId(), optionA.getId())); + + long count = voteParticipationRepository.countByVoteId(vote.getId()); + + assertThat(count).isEqualTo(2); + } + } } diff --git a/src/test/java/com/ject/vs/vote/domain/VoteParticipationTest.java b/src/test/java/com/ject/vs/vote/domain/VoteParticipationTest.java index cce878e5..66eb0dd5 100644 --- a/src/test/java/com/ject/vs/vote/domain/VoteParticipationTest.java +++ b/src/test/java/com/ject/vs/vote/domain/VoteParticipationTest.java @@ -8,20 +8,57 @@ class VoteParticipationTest { @Nested - class of { + class ofMember { @Test - void voteId와_userId가_올바르게_설정된다() { - // given - Long voteId = 1L; - Long userId = 2L; + void 회원_참여_정보가_올바르게_설정된다() { + VoteParticipation p = VoteParticipation.ofMember(1L, 2L, 10L); - // when - VoteParticipation voteParticipation = VoteParticipation.of(voteId, userId); + assertThat(p.getVoteId()).isEqualTo(1L); + assertThat(p.getUserId()).isEqualTo(2L); + assertThat(p.getOptionId()).isEqualTo(10L); + assertThat(p.getAnonymousId()).isNull(); + assertThat(p.isGuest()).isFalse(); + } + } + + @Nested + class ofGuest { + + @Test + void 비회원_참여_정보가_올바르게_설정된다() { + VoteParticipation p = VoteParticipation.ofGuest(1L, "anon-uuid", 10L); + + assertThat(p.getVoteId()).isEqualTo(1L); + assertThat(p.getAnonymousId()).isEqualTo("anon-uuid"); + assertThat(p.getOptionId()).isEqualTo(10L); + assertThat(p.getUserId()).isNull(); + assertThat(p.isGuest()).isTrue(); + } + } + + @Nested + class changeOption { - // then - assertThat(voteParticipation.getVoteId()).isEqualTo(voteId); - assertThat(voteParticipation.getUserId()).isEqualTo(userId); + @Test + void optionId가_변경된다() { + VoteParticipation p = VoteParticipation.ofMember(1L, 2L, 10L); + p.changeOption(20L); + assertThat(p.getOptionId()).isEqualTo(20L); + } + } + + @Nested + class isGuest { + + @Test + void 회원이면_false() { + assertThat(VoteParticipation.ofMember(1L, 2L, 10L).isGuest()).isFalse(); + } + + @Test + void 비회원이면_true() { + assertThat(VoteParticipation.ofGuest(1L, "uuid", 10L).isGuest()).isTrue(); } } } diff --git a/src/test/java/com/ject/vs/vote/domain/VoteTest.java b/src/test/java/com/ject/vs/vote/domain/VoteTest.java new file mode 100644 index 00000000..003f4c9c --- /dev/null +++ b/src/test/java/com/ject/vs/vote/domain/VoteTest.java @@ -0,0 +1,139 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.vote.exception.ImageRequiredException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class VoteTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @Nested + class create { + + @Test + void 일반형_투표_정상_생성() { + Vote vote = Vote.create( + VoteType.GENERAL, "제목", "내용", + "https://thumb.url", null, + Duration.ofHours(24), FIXED_CLOCK + ); + + assertThat(vote.getType()).isEqualTo(VoteType.GENERAL); + assertThat(vote.getTitle()).isEqualTo("제목"); + assertThat(vote.getStatus()).isEqualTo(VoteStatus.ONGOING); + assertThat(vote.getEndAt()).isEqualTo(Instant.parse("2025-01-02T00:00:00Z")); + } + + @Test + void 몰입형_투표_imageUrl_없으면_ImageRequiredException() { + assertThatThrownBy(() -> Vote.create( + VoteType.IMMERSIVE, "제목", "내용", + "https://thumb.url", null, + Duration.ofHours(24), FIXED_CLOCK + )).isInstanceOf(ImageRequiredException.class); + } + + @Test + void 몰입형_투표_imageUrl_빈문자열이면_ImageRequiredException() { + assertThatThrownBy(() -> Vote.create( + VoteType.IMMERSIVE, "제목", "내용", + "https://thumb.url", " ", + Duration.ofHours(24), FIXED_CLOCK + )).isInstanceOf(ImageRequiredException.class); + } + + @Test + void 몰입형_투표_imageUrl_있으면_정상_생성() { + Vote vote = Vote.create( + VoteType.IMMERSIVE, "제목", "내용", + "https://thumb.url", "https://image.url", + Duration.ofHours(24), FIXED_CLOCK + ); + + assertThat(vote.getType()).isEqualTo(VoteType.IMMERSIVE); + assertThat(vote.getImageUrl()).isEqualTo("https://image.url"); + } + } + + @Nested + class isOngoing { + + @Test + void endAt_이전이면_true() { + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + Clock beforeEnd = Clock.fixed(Instant.parse("2025-01-01T12:00:00Z"), ZoneOffset.UTC); + assertThat(vote.isOngoing(beforeEnd)).isTrue(); + } + + @Test + void endAt_이후이면_false() { + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + Clock afterEnd = Clock.fixed(Instant.parse("2025-01-03T00:00:00Z"), ZoneOffset.UTC); + assertThat(vote.isOngoing(afterEnd)).isFalse(); + } + } + + @Nested + class isEnded { + + @Test + void endAt_이후이면_true() { + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + Clock afterEnd = Clock.fixed(Instant.parse("2025-01-03T00:00:00Z"), ZoneOffset.UTC); + assertThat(vote.isEnded(afterEnd)).isTrue(); + } + } + + @Nested + class markEnded { + + @Test + void status가_ENDED로_변경된다() { + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + vote.markEnded(); + + assertThat(vote.getStatus()).isEqualTo(VoteStatus.ENDED); + } + } + + @Nested + class cacheAiInsight { + + @Test + void headline과_body가_저장되고_hasAiInsight가_true() { + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + vote.cacheAiInsight("헤드라인", "바디"); + + assertThat(vote.hasAiInsight()).isTrue(); + assertThat(vote.getAiInsightHeadline()).isEqualTo("헤드라인"); + assertThat(vote.getAiInsightBody()).isEqualTo("바디"); + } + + @Test + void 캐시_없으면_hasAiInsight가_false() { + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + assertThat(vote.hasAiInsight()).isFalse(); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/port/VoteQueryServiceTest.java b/src/test/java/com/ject/vs/vote/port/VoteQueryServiceTest.java new file mode 100644 index 00000000..9fdf9c24 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/VoteQueryServiceTest.java @@ -0,0 +1,178 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.VoteQueryUseCase.VoteRatio; +import com.ject.vs.vote.port.in.VoteQueryUseCase.VoteSummary; +import org.junit.jupiter.api.Nested; +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.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +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.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class VoteQueryServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @InjectMocks + private VoteQueryService voteQueryService; + + @Mock + private VoteRepository voteRepository; + + @Mock + private VoteOptionRepository voteOptionRepository; + + @Mock + private VoteParticipationRepository voteParticipationRepository; + + @Mock + private Clock clock; + + @Nested + class isParticipated { + + @Test + void 참여한_사용자는_true를_반환한다() { + given(voteParticipationRepository.existsByVoteIdAndUserId(1L, 2L)).willReturn(true); + assertThat(voteQueryService.isParticipated(1L, 2L)).isTrue(); + } + + @Test + void 참여하지_않은_사용자는_false를_반환한다() { + given(voteParticipationRepository.existsByVoteIdAndUserId(1L, 2L)).willReturn(false); + assertThat(voteQueryService.isParticipated(1L, 2L)).isFalse(); + } + } + + @Nested + class getSelectedOptionId { + + @Test + void 참여한_경우_optionId를_반환한다() { + VoteParticipation p = VoteParticipation.ofMember(1L, 2L, 10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(p)); + + Optional result = voteQueryService.getSelectedOptionId(1L, 2L); + + assertThat(result).contains(10L); + } + + @Test + void 참여하지_않은_경우_empty를_반환한다() { + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.empty()); + + Optional result = voteQueryService.getSelectedOptionId(1L, 2L); + + assertThat(result).isEmpty(); + } + } + + @Nested + class getVoteSummary { + + @Test + void 진행중인_투표의_summary를_반환한다() { + Clock nowClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + Vote vote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), nowClock); + given(voteRepository.findById(1L)).willReturn(Optional.of(vote)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T12:00:00Z")); + + VoteSummary summary = voteQueryService.getVoteSummary(1L); + + assertThat(summary.title()).isEqualTo("제목"); + assertThat(summary.status()).isEqualTo(VoteStatus.ONGOING); + } + + @Test + void 존재하지_않는_voteId는_VoteNotFoundException() { + given(voteRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> voteQueryService.getVoteSummary(999L)) + .isInstanceOf(VoteNotFoundException.class); + } + } + + @Nested + class getRatio { + + @Test + void 옵션_비율을_올바르게_계산한다() { + VoteOption optA = VoteOption.of(1L, "A", 0); + VoteOption optB = VoteOption.of(1L, "B", 1); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of(optA, optB)); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(4L); + given(voteParticipationRepository.countByVoteIdAndOptionId(1L, optA.getId())).willReturn(3L); + + VoteRatio ratio = voteQueryService.getRatio(1L); + + assertThat(ratio.optionARatio()).isEqualTo(75); + assertThat(ratio.optionBRatio()).isEqualTo(25); + assertThat(ratio.participantCount()).isEqualTo(4); + } + + @Test + void 참여자_없으면_비율_0() { + VoteOption optA = VoteOption.of(1L, "A", 0); + VoteOption optB = VoteOption.of(1L, "B", 1); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of(optA, optB)); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(0L); + given(voteParticipationRepository.countByVoteIdAndOptionId(1L, optA.getId())).willReturn(0L); + + VoteRatio ratio = voteQueryService.getRatio(1L); + + assertThat(ratio.optionARatio()).isEqualTo(0); + assertThat(ratio.optionBRatio()).isEqualTo(100); + } + } + + @Nested + class getParticipantCount { + + @Test + void 참여자_수를_반환한다() { + given(voteParticipationRepository.countByVoteId(1L)).willReturn(10L); + assertThat(voteQueryService.getParticipantCount(1L)).isEqualTo(10); + } + } + + @Nested + class findAllVoteIdsByStatus { + + @Test + void ONGOING_필터_적용() { + Clock nowClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + Vote ongoing = Vote.create(VoteType.GENERAL, "진행중", null, "t", null, + Duration.ofHours(24), nowClock); + Vote ended = Vote.create(VoteType.GENERAL, "종료됨", null, "t", null, + Duration.ofHours(1), nowClock); + + given(voteRepository.findAllByIdIn(List.of(1L, 2L))).willReturn(List.of(ongoing, ended)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + + List result = voteQueryService.findAllVoteIdsByStatus(List.of(1L, 2L), VoteStatus.ONGOING); + + assertThat(result).hasSize(1); + } + + @Test + void 빈_목록을_전달하면_빈_목록을_반환한다() { + List result = voteQueryService.findAllVoteIdsByStatus(List.of(), VoteStatus.ONGOING); + assertThat(result).isEmpty(); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/port/VoteServiceTest.java b/src/test/java/com/ject/vs/vote/port/VoteServiceTest.java deleted file mode 100644 index 2d841b2c..00000000 --- a/src/test/java/com/ject/vs/vote/port/VoteServiceTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.ject.vs.vote.port; - -import com.ject.vs.vote.domain.VoteParticipationRepository; -import com.ject.vs.vote.port.in.dto.VoteStatus; -import org.junit.jupiter.api.Nested; -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 static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class VoteServiceTest { - - @InjectMocks - private VoteService voteService; - - @Mock - private VoteParticipationRepository voteParticipationRepository; - - @Nested - class isParticipant { - - @Test - void 참여한_사용자는_true를_반환한다() { - // given - given(voteParticipationRepository.existsByVoteIdAndUserId(1L, 2L)).willReturn(true); - - // when - boolean result = voteService.isParticipant(1L, 2L); - - // then - assertThat(result).isTrue(); - } - - @Test - void 참여하지_않은_사용자는_false를_반환한다() { - // given - given(voteParticipationRepository.existsByVoteIdAndUserId(1L, 2L)).willReturn(false); - - // when - boolean result = voteService.isParticipant(1L, 2L); - - // then - assertThat(result).isFalse(); - } - } - - @Nested - class findAllVoteIdsByUserId { - - @Test - void 유저가_참여한_voteId_목록을_반환한다() { - // given - given(voteParticipationRepository.findAllVoteIdsByUserId(1L)).willReturn(List.of(10L, 20L)); - - // when - List result = voteService.findAllVoteIdsByUserId(1L); - - // then - assertThat(result).containsExactly(10L, 20L); - } - - @Test - void 참여한_투표가_없으면_빈_목록을_반환한다() { - // given - given(voteParticipationRepository.findAllVoteIdsByUserId(1L)).willReturn(List.of()); - - // when - List result = voteService.findAllVoteIdsByUserId(1L); - - // then - assertThat(result).isEmpty(); - } - } - - @Nested - class countParticipantsByVoteId { - - @Test - void 투표_참여자_수를_반환한다() { - // given - given(voteParticipationRepository.countByVoteId(1L)).willReturn(25L); - - // when - long result = voteService.countParticipantsByVoteId(1L); - - // then - assertThat(result).isEqualTo(25L); - verify(voteParticipationRepository).countByVoteId(1L); - } - } - - @Nested - class findAllUserIdsByVoteId { - - @Test - void 투표에_참여한_userId_목록을_반환한다() { - // given - given(voteParticipationRepository.findAllUserIdsByVoteId(1L)).willReturn(List.of(100L, 200L)); - - // when - List result = voteService.findAllUserIdsByVoteId(1L); - - // then - assertThat(result).containsExactly(100L, 200L); - } - } - - @Nested - class findAllVoteIdsByStatus { - - @Test - void ONGOING_상태로_조회하면_전달된_voteId_목록을_그대로_반환한다() { - // given - List voteIds = List.of(1L, 2L, 3L); - - // when - List result = voteService.findAllVoteIdsByStatus(voteIds, VoteStatus.ONGOING); - - // then - // TODO: Vote 도메인 연동 후 실제 status 필터링 검증으로 교체 - assertThat(result).isEqualTo(voteIds); - } - - @Test - void 빈_목록을_전달하면_빈_목록을_반환한다() { - // given - List voteIds = List.of(); - - // when - List result = voteService.findAllVoteIdsByStatus(voteIds, VoteStatus.ENDED); - - // then - assertThat(result).isEmpty(); - } - } -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 71a77531..ac5bf855 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=TYPE,VALUE driver-class-name: org.h2.Driver username: sa password: From 712cfec27f473c9ccd815704c80252715b300041 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 15:36:46 +0900 Subject: [PATCH 08/36] feat: add anonymous id cookie resolver and web mvc config --- .../java/com/ject/vs/config/AnonymousId.java | 11 ++++ .../ject/vs/config/AnonymousIdResolver.java | 61 +++++++++++++++++++ .../java/com/ject/vs/config/WebConfig.java | 20 ++++++ 3 files changed, 92 insertions(+) create mode 100644 src/main/java/com/ject/vs/config/AnonymousId.java create mode 100644 src/main/java/com/ject/vs/config/AnonymousIdResolver.java create mode 100644 src/main/java/com/ject/vs/config/WebConfig.java diff --git a/src/main/java/com/ject/vs/config/AnonymousId.java b/src/main/java/com/ject/vs/config/AnonymousId.java new file mode 100644 index 00000000..37941358 --- /dev/null +++ b/src/main/java/com/ject/vs/config/AnonymousId.java @@ -0,0 +1,11 @@ +package com.ject.vs.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AnonymousId { +} diff --git a/src/main/java/com/ject/vs/config/AnonymousIdResolver.java b/src/main/java/com/ject/vs/config/AnonymousIdResolver.java new file mode 100644 index 00000000..64a83962 --- /dev/null +++ b/src/main/java/com/ject/vs/config/AnonymousIdResolver.java @@ -0,0 +1,61 @@ +package com.ject.vs.config; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.time.Duration; +import java.util.Arrays; +import java.util.UUID; + +@Component +public class AnonymousIdResolver implements HandlerMethodArgumentResolver { + + private static final String COOKIE_NAME = "anonymous_id"; + private static final Duration MAX_AGE = Duration.ofDays(365); + + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.hasParameterAnnotation(AnonymousId.class) + && parameter.getParameterType().equals(String.class); + } + + @Override + public String resolveArgument(org.springframework.core.MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest req = webRequest.getNativeRequest(HttpServletRequest.class); + HttpServletResponse res = webRequest.getNativeResponse(HttpServletResponse.class); + + String existing = extractCookie(req, COOKIE_NAME); + if (existing != null) return existing; + + String newId = UUID.randomUUID().toString(); + ResponseCookie cookie = ResponseCookie.from(COOKIE_NAME, newId) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(MAX_AGE) + .path("/") + .build(); + res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + return newId; + } + + private String extractCookie(HttpServletRequest req, String name) { + if (req == null || req.getCookies() == null) return null; + return Arrays.stream(req.getCookies()) + .filter(c -> name.equals(c.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/ject/vs/config/WebConfig.java b/src/main/java/com/ject/vs/config/WebConfig.java new file mode 100644 index 00000000..06dafc6a --- /dev/null +++ b/src/main/java/com/ject/vs/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.ject.vs.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AnonymousIdResolver anonymousIdResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(anonymousIdResolver); + } +} From 90d65f19a77de50b417b2b55bbf2c2c841b72805 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 15:36:51 +0900 Subject: [PATCH 09/36] feat: add GuestFreeVote domain entity and service --- .../ject/vs/vote/domain/GuestFreeVote.java | 60 +++++++++++++++++++ .../vote/domain/GuestFreeVoteRepository.java | 6 ++ .../vs/vote/port/GuestFreeVoteService.java | 32 ++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/domain/GuestFreeVote.java create mode 100644 src/main/java/com/ject/vs/vote/domain/GuestFreeVoteRepository.java create mode 100644 src/main/java/com/ject/vs/vote/port/GuestFreeVoteService.java diff --git a/src/main/java/com/ject/vs/vote/domain/GuestFreeVote.java b/src/main/java/com/ject/vs/vote/domain/GuestFreeVote.java new file mode 100644 index 00000000..1ec9b36b --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/GuestFreeVote.java @@ -0,0 +1,60 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.vote.exception.VoteFreeLimitExceededException; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Clock; +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table(name = "guest_free_vote") +@Getter +@NoArgsConstructor(access = PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class GuestFreeVote { + + private static final int MAX_FREE_VOTES = 5; + + @Id + private String anonymousId; + + private int consumedCount; + private Instant lastConsumedAt; + + @CreatedDate + @Column(updatable = false) + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; + + public static GuestFreeVote create(String anonymousId) { + GuestFreeVote g = new GuestFreeVote(); + g.anonymousId = anonymousId; + g.consumedCount = 0; + return g; + } + + public void consume(Clock clock) { + if (consumedCount >= MAX_FREE_VOTES) { + throw new VoteFreeLimitExceededException(); + } + consumedCount++; + lastConsumedAt = Instant.now(clock); + } + + public int remaining() { + return MAX_FREE_VOTES - consumedCount; + } + + public static int totalFreeVotes() { + return MAX_FREE_VOTES; + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/GuestFreeVoteRepository.java b/src/main/java/com/ject/vs/vote/domain/GuestFreeVoteRepository.java new file mode 100644 index 00000000..de10ac16 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/GuestFreeVoteRepository.java @@ -0,0 +1,6 @@ +package com.ject.vs.vote.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GuestFreeVoteRepository extends JpaRepository { +} diff --git a/src/main/java/com/ject/vs/vote/port/GuestFreeVoteService.java b/src/main/java/com/ject/vs/vote/port/GuestFreeVoteService.java new file mode 100644 index 00000000..04d76025 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/GuestFreeVoteService.java @@ -0,0 +1,32 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.GuestFreeVote; +import com.ject.vs.vote.domain.GuestFreeVoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; + +@Service +@RequiredArgsConstructor +@Transactional +public class GuestFreeVoteService { + + private final GuestFreeVoteRepository repository; + private final Clock clock; + + public void consume(String anonymousId) { + GuestFreeVote g = repository.findById(anonymousId) + .orElseGet(() -> GuestFreeVote.create(anonymousId)); + g.consume(clock); + repository.save(g); + } + + @Transactional(readOnly = true) + public int remaining(String anonymousId) { + return repository.findById(anonymousId) + .map(GuestFreeVote::remaining) + .orElse(GuestFreeVote.totalFreeVotes()); + } +} From a090bc0e1cc278998496da2e26a9cec9ae67fde3 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 15:36:55 +0900 Subject: [PATCH 10/36] test: add STEP 2 anonymous id and guest free vote tests --- .../vs/config/AnonymousIdResolverTest.java | 91 ++++++++++++++++ .../vs/vote/domain/GuestFreeVoteTest.java | 71 ++++++++++++ .../vote/port/GuestFreeVoteServiceTest.java | 102 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 src/test/java/com/ject/vs/config/AnonymousIdResolverTest.java create mode 100644 src/test/java/com/ject/vs/vote/domain/GuestFreeVoteTest.java create mode 100644 src/test/java/com/ject/vs/vote/port/GuestFreeVoteServiceTest.java diff --git a/src/test/java/com/ject/vs/config/AnonymousIdResolverTest.java b/src/test/java/com/ject/vs/config/AnonymousIdResolverTest.java new file mode 100644 index 00000000..ef991078 --- /dev/null +++ b/src/test/java/com/ject/vs/config/AnonymousIdResolverTest.java @@ -0,0 +1,91 @@ +package com.ject.vs.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.context.request.ServletWebRequest; + +import jakarta.servlet.http.Cookie; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnonymousIdResolverTest { + + private AnonymousIdResolver resolver; + + @BeforeEach + void setUp() { + resolver = new AnonymousIdResolver(); + } + + @Nested + class resolveArgument { + + @Test + void 쿠키가_있으면_기존_값을_그대로_반환한다() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setCookies(new Cookie("anonymous_id", "existing-uuid")); + MockHttpServletResponse res = new MockHttpServletResponse(); + + String result = (String) resolver.resolveArgument( + null, null, new ServletWebRequest(req, res), null); + + assertThat(result).isEqualTo("existing-uuid"); + assertThat(res.getHeader("Set-Cookie")).isNull(); + } + + @Test + void 쿠키가_없으면_새_UUID를_발급하고_Set_Cookie_헤더를_추가한다() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + String result = (String) resolver.resolveArgument( + null, null, new ServletWebRequest(req, res), null); + + assertThat(result).isNotBlank(); + assertThat(result).matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + assertThat(res.getHeader("Set-Cookie")).contains("anonymous_id=" + result); + assertThat(res.getHeader("Set-Cookie")).contains("HttpOnly"); + assertThat(res.getHeader("Set-Cookie")).contains("SameSite=None"); + } + + @Test + void 쿠키_배열이_null인_경우_새_UUID를_발급한다() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + // no cookies set + MockHttpServletResponse res = new MockHttpServletResponse(); + + String result = (String) resolver.resolveArgument( + null, null, new ServletWebRequest(req, res), null); + + assertThat(result).isNotBlank(); + } + } + + @Nested + class supportsParameter { + + @Test + void AnonymousId_어노테이션이_붙은_String_파라미터를_지원한다() throws NoSuchMethodException { + var method = TestController.class.getMethod("testMethod", String.class); + var param = new org.springframework.core.MethodParameter(method, 0); + + assertThat(resolver.supportsParameter(param)).isTrue(); + } + + @Test + void AnonymousId_어노테이션이_없으면_지원하지_않는다() throws NoSuchMethodException { + var method = TestController.class.getMethod("noAnnotationMethod", String.class); + var param = new org.springframework.core.MethodParameter(method, 0); + + assertThat(resolver.supportsParameter(param)).isFalse(); + } + + static class TestController { + public void testMethod(@AnonymousId String id) {} + public void noAnnotationMethod(String id) {} + } + } +} diff --git a/src/test/java/com/ject/vs/vote/domain/GuestFreeVoteTest.java b/src/test/java/com/ject/vs/vote/domain/GuestFreeVoteTest.java new file mode 100644 index 00000000..c0e7a880 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/domain/GuestFreeVoteTest.java @@ -0,0 +1,71 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.vote.exception.VoteFreeLimitExceededException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GuestFreeVoteTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @Nested + class consume { + + @Test + void 최초_생성_후_다섯번까지_소비할_수_있다() { + GuestFreeVote g = GuestFreeVote.create("anon-1"); + + for (int i = 0; i < 5; i++) { + g.consume(FIXED_CLOCK); + } + + assertThat(g.getConsumedCount()).isEqualTo(5); + assertThat(g.remaining()).isEqualTo(0); + } + + @Test + void 여섯번째_소비시_VoteFreeLimitExceededException을_던진다() { + GuestFreeVote g = GuestFreeVote.create("anon-2"); + for (int i = 0; i < 5; i++) { + g.consume(FIXED_CLOCK); + } + + assertThatThrownBy(() -> g.consume(FIXED_CLOCK)) + .isInstanceOf(VoteFreeLimitExceededException.class); + } + + @Test + void 소비_후_lastConsumedAt이_설정된다() { + GuestFreeVote g = GuestFreeVote.create("anon-3"); + + g.consume(FIXED_CLOCK); + + assertThat(g.getLastConsumedAt()).isEqualTo(Instant.parse("2025-01-01T00:00:00Z")); + } + } + + @Nested + class remaining { + + @Test + void 신규_생성_시_잔여_횟수는_5이다() { + GuestFreeVote g = GuestFreeVote.create("anon-4"); + assertThat(g.remaining()).isEqualTo(5); + } + + @Test + void 두번_소비_후_잔여는_3이다() { + GuestFreeVote g = GuestFreeVote.create("anon-5"); + g.consume(FIXED_CLOCK); + g.consume(FIXED_CLOCK); + assertThat(g.remaining()).isEqualTo(3); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/port/GuestFreeVoteServiceTest.java b/src/test/java/com/ject/vs/vote/port/GuestFreeVoteServiceTest.java new file mode 100644 index 00000000..b0aa8a11 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/GuestFreeVoteServiceTest.java @@ -0,0 +1,102 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.GuestFreeVote; +import com.ject.vs.vote.domain.GuestFreeVoteRepository; +import com.ject.vs.vote.exception.VoteFreeLimitExceededException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +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.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class GuestFreeVoteServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @InjectMocks + private GuestFreeVoteService service; + + @Mock + private GuestFreeVoteRepository repository; + + @Mock + private Clock clock; + + @Nested + class consume { + + @Test + void 신규_anonymousId_진입_시_새_row를_생성하고_consume한다() { + given(repository.findById("new-anon")).willReturn(Optional.empty()); + given(repository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + + service.consume("new-anon"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GuestFreeVote.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getConsumedCount()).isEqualTo(1); + } + + @Test + void 기존_anonymousId는_기존_row에_consume한다() { + GuestFreeVote existing = GuestFreeVote.create("existing-anon"); + given(repository.findById("existing-anon")).willReturn(Optional.of(existing)); + given(repository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + + service.consume("existing-anon"); + + assertThat(existing.getConsumedCount()).isEqualTo(1); + } + + @Test + void 다섯번_소진된_anonymousId는_VoteFreeLimitExceededException을_던진다() { + GuestFreeVote exhausted = GuestFreeVote.create("exhausted-anon"); + for (int i = 0; i < 5; i++) exhausted.consume(FIXED_CLOCK); + given(repository.findById("exhausted-anon")).willReturn(Optional.of(exhausted)); + + assertThatThrownBy(() -> service.consume("exhausted-anon")) + .isInstanceOf(VoteFreeLimitExceededException.class); + } + } + + @Nested + class remaining { + + @Test + void 신규_anonymousId는_총_5회를_반환한다() { + given(repository.findById("unknown")).willReturn(Optional.empty()); + + int result = service.remaining("unknown"); + + assertThat(result).isEqualTo(5); + } + + @Test + void 두번_소진된_경우_잔여는_3을_반환한다() { + GuestFreeVote g = GuestFreeVote.create("anon-2consumed"); + g.consume(FIXED_CLOCK); + g.consume(FIXED_CLOCK); + given(repository.findById("anon-2consumed")).willReturn(Optional.of(g)); + + int result = service.remaining("anon-2consumed"); + + assertThat(result).isEqualTo(3); + } + } +} From ea80f9db0f5308b1a6d056578578d9b84743a4b0 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:05:27 +0900 Subject: [PATCH 11/36] feat: add VoteEmojiReaction entity and repository --- .../vs/vote/domain/VoteEmojiReaction.java | 54 +++++++++++++++++++ .../domain/VoteEmojiReactionRepository.java | 20 +++++++ 2 files changed, 74 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteEmojiReaction.java create mode 100644 src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java diff --git a/src/main/java/com/ject/vs/vote/domain/VoteEmojiReaction.java b/src/main/java/com/ject/vs/vote/domain/VoteEmojiReaction.java new file mode 100644 index 00000000..187ff448 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteEmojiReaction.java @@ -0,0 +1,54 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.common.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table( + name = "vote_emoji_reaction", + uniqueConstraints = { + @UniqueConstraint(name = "uq_member_emoji", columnNames = {"vote_id", "user_id"}), + @UniqueConstraint(name = "uq_guest_emoji", columnNames = {"vote_id", "anonymous_id"}) + } +) +@Getter +@NoArgsConstructor(access = PROTECTED) +public class VoteEmojiReaction extends BaseTimeEntity { + + @Column(name = "vote_id", nullable = false) + private Long voteId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "anonymous_id") + private String anonymousId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private VoteEmoji emoji; + + public static VoteEmojiReaction ofMember(Long voteId, Long userId, VoteEmoji emoji) { + VoteEmojiReaction r = new VoteEmojiReaction(); + r.voteId = voteId; + r.userId = userId; + r.emoji = emoji; + return r; + } + + public static VoteEmojiReaction ofGuest(Long voteId, String anonymousId, VoteEmoji emoji) { + VoteEmojiReaction r = new VoteEmojiReaction(); + r.voteId = voteId; + r.anonymousId = anonymousId; + r.emoji = emoji; + return r; + } + + public void changeEmoji(VoteEmoji newEmoji) { + this.emoji = newEmoji; + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java new file mode 100644 index 00000000..56493fc9 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java @@ -0,0 +1,20 @@ +package com.ject.vs.vote.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Map; +import java.util.Optional; + +public interface VoteEmojiReactionRepository extends JpaRepository { + + Optional findByVoteIdAndUserId(Long voteId, Long userId); + + Optional findByVoteIdAndAnonymousId(Long voteId, String anonymousId); + + @Query("SELECT r.emoji, COUNT(r) FROM VoteEmojiReaction r WHERE r.voteId = :voteId GROUP BY r.emoji") + java.util.List countByEmojiForVote(@Param("voteId") Long voteId); + + long countByVoteId(Long voteId); +} From c087f518649e871659df5ca0a5b76b731df5cd4e Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:05:32 +0900 Subject: [PATCH 12/36] feat: define VoteCommandUseCase and VoteEmojiCommandUseCase inbound ports --- .../vs/vote/port/in/VoteCommandUseCase.java | 50 +++++++++++++++++++ .../vote/port/in/VoteEmojiCommandUseCase.java | 17 +++++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java create mode 100644 src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java new file mode 100644 index 00000000..e3f0d427 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java @@ -0,0 +1,50 @@ +package com.ject.vs.vote.port.in; + +import com.ject.vs.vote.domain.Vote; +import com.ject.vs.vote.domain.VoteDuration; +import com.ject.vs.vote.domain.VoteStatus; +import com.ject.vs.vote.domain.VoteType; + +import java.time.Instant; +import java.util.List; + +public interface VoteCommandUseCase { + + VoteCreateResult create(VoteCreateCommand command); + + ParticipateResult participateAsMember(Long voteId, Long userId, Long optionId); + + ParticipateResult participateAsGuest(Long voteId, String anonymousId, Long optionId); + + void cancel(Long voteId, Long userId); + + record VoteCreateCommand( + VoteType type, + String title, + String content, + String thumbnailUrl, + String imageUrl, + VoteDuration duration, + String optionA, + String optionB + ) { + } + + record VoteCreateResult(Long voteId, VoteStatus status, Instant endAt) { + public static VoteCreateResult from(Vote vote) { + return new VoteCreateResult(vote.getId(), vote.getStatus(), vote.getEndAt()); + } + } + + record ParticipateResult( + Long voteId, + Long selectedOptionId, + List options, + int participantCount, + Integer remainingFreeVotes + ) { + } + + record OptionResult(Long optionId, String label, long voteCount, Integer ratio) { + } +} diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java new file mode 100644 index 00000000..be8fcdc1 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java @@ -0,0 +1,17 @@ +package com.ject.vs.vote.port.in; + +import com.ject.vs.vote.domain.VoteEmoji; + +import java.util.Map; + +public interface VoteEmojiCommandUseCase { + + /** emoji == null 이면 취소 */ + EmojiResult reactAsMember(Long voteId, Long userId, VoteEmoji emoji); + + /** emoji == null 이면 취소 */ + EmojiResult reactAsGuest(Long voteId, String anonymousId, VoteEmoji emoji); + + record EmojiResult(Map emojiSummary, long total, VoteEmoji myEmoji) { + } +} From a5de8a1f74ab73ca0cabff73b90ee6f574acd68b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:05:41 +0900 Subject: [PATCH 13/36] feat: implement VoteCommandService and VoteEmojiCommandService --- .../ject/vs/vote/port/VoteCommandService.java | 104 ++++++++++++++++++ .../vs/vote/port/VoteEmojiCommandService.java | 71 ++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/port/VoteCommandService.java create mode 100644 src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java diff --git a/src/main/java/com/ject/vs/vote/port/VoteCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java new file mode 100644 index 00000000..3687c290 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java @@ -0,0 +1,104 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.InvalidOptionException; +import com.ject.vs.vote.exception.VoteEndedException; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.VoteCommandUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class VoteCommandService implements VoteCommandUseCase { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final GuestFreeVoteService guestFreeVoteService; + private final Clock clock; + + @Override + public VoteCreateResult create(VoteCreateCommand cmd) { + Vote vote = Vote.create( + cmd.type(), cmd.title(), cmd.content(), + cmd.thumbnailUrl(), cmd.imageUrl(), + cmd.duration().getValue(), + clock + ); + Vote saved = voteRepository.save(vote); + voteOptionRepository.save(VoteOption.of(saved.getId(), cmd.optionA(), 0)); + voteOptionRepository.save(VoteOption.of(saved.getId(), cmd.optionB(), 1)); + return VoteCreateResult.from(saved); + } + + @Override + public ParticipateResult participateAsMember(Long voteId, Long userId, Long optionId) { + loadOngoingVote(voteId); + validateOption(voteId, optionId); + + Optional existing = + voteParticipationRepository.findByVoteIdAndUserId(voteId, userId); + + if (existing.isPresent()) { + existing.get().changeOption(optionId); + } else { + voteParticipationRepository.save(VoteParticipation.ofMember(voteId, userId, optionId)); + } + return buildResult(voteId, optionId, null); + } + + @Override + public ParticipateResult participateAsGuest(Long voteId, String anonymousId, Long optionId) { + loadOngoingVote(voteId); + validateOption(voteId, optionId); + + Optional existing = + voteParticipationRepository.findByVoteIdAndAnonymousId(voteId, anonymousId); + + if (existing.isPresent()) { + existing.get().changeOption(optionId); + } else { + guestFreeVoteService.consume(anonymousId); + voteParticipationRepository.save(VoteParticipation.ofGuest(voteId, anonymousId, optionId)); + } + return buildResult(voteId, optionId, guestFreeVoteService.remaining(anonymousId)); + } + + @Override + public void cancel(Long voteId, Long userId) { + loadOngoingVote(voteId); + voteParticipationRepository.deleteByVoteIdAndUserId(voteId, userId); + } + + private Vote loadOngoingVote(Long voteId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + if (vote.isEnded(clock)) throw new VoteEndedException(); + return vote; + } + + private void validateOption(Long voteId, Long optionId) { + if (!voteOptionRepository.existsByIdAndVoteId(optionId, voteId)) { + throw new InvalidOptionException(); + } + } + + private ParticipateResult buildResult(Long voteId, Long selectedOptionId, Integer remaining) { + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + long total = voteParticipationRepository.countByVoteId(voteId); + + List optionResults = options.stream().map(opt -> { + long count = voteParticipationRepository.countByVoteIdAndOptionId(voteId, opt.getId()); + int ratio = total == 0 ? 0 : (int) Math.round(count * 100.0 / total); + return new OptionResult(opt.getId(), opt.getLabel(), count, ratio); + }).toList(); + + return new ParticipateResult(voteId, selectedOptionId, optionResults, (int) total, remaining); + } +} diff --git a/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java new file mode 100644 index 00000000..180fd7cd --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java @@ -0,0 +1,71 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class VoteEmojiCommandService implements VoteEmojiCommandUseCase { + + private final VoteEmojiReactionRepository reactionRepository; + + @Override + public EmojiResult reactAsMember(Long voteId, Long userId, VoteEmoji emoji) { + Optional existing = reactionRepository.findByVoteIdAndUserId(voteId, userId); + VoteEmoji resultEmoji = applyReaction(existing, emoji, + () -> reactionRepository.save(VoteEmojiReaction.ofMember(voteId, userId, emoji))); + return buildResult(voteId, resultEmoji); + } + + @Override + public EmojiResult reactAsGuest(Long voteId, String anonymousId, VoteEmoji emoji) { + Optional existing = reactionRepository.findByVoteIdAndAnonymousId(voteId, anonymousId); + VoteEmoji resultEmoji = applyReaction(existing, emoji, + () -> reactionRepository.save(VoteEmojiReaction.ofGuest(voteId, anonymousId, emoji))); + return buildResult(voteId, resultEmoji); + } + + /** + * emoji == null → 취소 (delete) + * 같은 emoji 재클릭 → 취소 (delete) + * 다른 emoji → 교체 (update) + * 기존 없음 + emoji 있음 → 신규 (create via newReactionSaver) + * Returns the current emoji after the operation (null if canceled/deleted). + */ + private VoteEmoji applyReaction(Optional existing, + VoteEmoji emoji, + Runnable newReactionSaver) { + if (existing.isPresent()) { + VoteEmojiReaction reaction = existing.get(); + if (emoji == null || reaction.getEmoji() == emoji) { + reactionRepository.delete(reaction); + return null; + } + reaction.changeEmoji(emoji); + return emoji; + } + if (emoji == null) return null; + newReactionSaver.run(); + return emoji; + } + + private EmojiResult buildResult(Long voteId, VoteEmoji myEmoji) { + Map summary = Arrays.stream(VoteEmoji.values()) + .collect(Collectors.toMap(e -> e, e -> 0L)); + + reactionRepository.countByEmojiForVote(voteId) + .forEach(row -> summary.put((VoteEmoji) row[0], (Long) row[1])); + + long total = summary.values().stream().mapToLong(Long::longValue).sum(); + return new EmojiResult(summary, total, myEmoji); + } +} From 10983fad93102be126ea887cb3765a9bee1dd6ab Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:05:45 +0900 Subject: [PATCH 14/36] feat: add GlobalExceptionHandler for all vote domain exceptions --- .../exception/GlobalExceptionHandler.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..26d9ea88 --- /dev/null +++ b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.ject.vs.common.exception; + +import com.ject.vs.vote.exception.*; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(VoteNotFoundException.class) + public ResponseEntity handleVoteNotFound(VoteNotFoundException e) { + return ResponseEntity.status(404).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(VoteEndedException.class) + public ResponseEntity handleVoteEnded(VoteEndedException e) { + return ResponseEntity.status(403).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(VoteNotEndedException.class) + public ResponseEntity handleVoteNotEnded(VoteNotEndedException e) { + return ResponseEntity.status(403).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(VoteFreeLimitExceededException.class) + public ResponseEntity handleVoteFreeLimit(VoteFreeLimitExceededException e) { + return ResponseEntity.status(403).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(InvalidOptionException.class) + public ResponseEntity handleInvalidOption(InvalidOptionException e) { + return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(InvalidEmojiException.class) + public ResponseEntity handleInvalidEmoji(InvalidEmojiException e) { + return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(InvalidDurationException.class) + public ResponseEntity handleInvalidDuration(InvalidDurationException e) { + return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(ImageRequiredException.class) + public ResponseEntity handleImageRequired(ImageRequiredException e) { + return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + public record ErrorResponse(String code, String message) { + } +} From a7c204da05a9f7dc96ac6fe21533d3270def2048 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:05:48 +0900 Subject: [PATCH 15/36] test: add VoteCommandService and VoteEmojiCommandService tests --- .../vs/vote/port/VoteCommandServiceTest.java | 226 ++++++++++++++++++ .../port/VoteEmojiCommandServiceTest.java | 102 ++++++++ 2 files changed, 328 insertions(+) create mode 100644 src/test/java/com/ject/vs/vote/port/VoteCommandServiceTest.java create mode 100644 src/test/java/com/ject/vs/vote/port/VoteEmojiCommandServiceTest.java diff --git a/src/test/java/com/ject/vs/vote/port/VoteCommandServiceTest.java b/src/test/java/com/ject/vs/vote/port/VoteCommandServiceTest.java new file mode 100644 index 00000000..17d13c8e --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/VoteCommandServiceTest.java @@ -0,0 +1,226 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.InvalidOptionException; +import com.ject.vs.vote.exception.VoteEndedException; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.VoteCommandUseCase; +import com.ject.vs.vote.port.in.VoteCommandUseCase.ParticipateResult; +import com.ject.vs.vote.port.in.VoteCommandUseCase.VoteCreateCommand; +import com.ject.vs.vote.port.in.VoteCommandUseCase.VoteCreateResult; +import org.junit.jupiter.api.Nested; +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.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +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.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class VoteCommandServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @InjectMocks + private VoteCommandService service; + + @Mock + private VoteRepository voteRepository; + + @Mock + private VoteOptionRepository voteOptionRepository; + + @Mock + private VoteParticipationRepository voteParticipationRepository; + + @Mock + private GuestFreeVoteService guestFreeVoteService; + + @Mock + private Clock clock; + + @Nested + class create { + + @Test + void 정상적으로_투표를_생성한다() { + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + VoteCreateCommand cmd = new VoteCreateCommand( + VoteType.GENERAL, "제목", null, "thumb", null, + VoteDuration.HOURS_24, "A", "B"); + + Vote fakeVote = Vote.create(VoteType.GENERAL, "제목", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + given(voteRepository.save(any())).willReturn(fakeVote); + VoteOption optA = VoteOption.of(1L, "A", 0); + VoteOption optB = VoteOption.of(1L, "B", 1); + given(voteOptionRepository.save(any())).willReturn(optA, optB); + + VoteCreateResult result = service.create(cmd); + + assertThat(result.status()).isEqualTo(VoteStatus.ONGOING); + } + + @Test + void IMMERSIVE_타입에_imageUrl_없으면_ImageRequiredException() { + VoteCreateCommand cmd = new VoteCreateCommand( + VoteType.IMMERSIVE, "제목", null, "thumb", null, + VoteDuration.HOURS_24, "A", "B"); + + assertThatThrownBy(() -> service.create(cmd)) + .isInstanceOf(com.ject.vs.vote.exception.ImageRequiredException.class); + } + } + + @Nested + class participateAsMember { + + @Test + void 신규_회원_참여_정상_저장() { + Vote ongoingVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(24), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + given(voteOptionRepository.existsByIdAndVoteId(10L, 1L)).willReturn(true); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.empty()); + given(voteParticipationRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(1L); + + ParticipateResult result = service.participateAsMember(1L, 2L, 10L); + + assertThat(result.selectedOptionId()).isEqualTo(10L); + assertThat(result.participantCount()).isEqualTo(1); + verify(voteParticipationRepository).save(any()); + } + + @Test + void 기존_참여자는_옵션을_변경한다() { + Vote ongoingVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(24), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + given(voteOptionRepository.existsByIdAndVoteId(20L, 1L)).willReturn(true); + VoteParticipation existing = VoteParticipation.ofMember(1L, 2L, 10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(existing)); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(1L); + + service.participateAsMember(1L, 2L, 20L); + + assertThat(existing.getOptionId()).isEqualTo(20L); + } + + @Test + void 종료된_투표에_참여하면_VoteEndedException() { + Vote endedVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(1), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); + + assertThatThrownBy(() -> service.participateAsMember(1L, 2L, 10L)) + .isInstanceOf(VoteEndedException.class); + } + + @Test + void 존재하지_않는_투표는_VoteNotFoundException() { + given(voteRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.participateAsMember(999L, 2L, 10L)) + .isInstanceOf(VoteNotFoundException.class); + } + + @Test + void 유효하지_않은_optionId는_InvalidOptionException() { + Vote ongoingVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(24), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + given(voteOptionRepository.existsByIdAndVoteId(99L, 1L)).willReturn(false); + + assertThatThrownBy(() -> service.participateAsMember(1L, 2L, 99L)) + .isInstanceOf(InvalidOptionException.class); + } + } + + @Nested + class participateAsGuest { + + @Test + void 신규_비회원은_차감_후_참여_저장() { + Vote ongoingVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(24), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + given(voteOptionRepository.existsByIdAndVoteId(10L, 1L)).willReturn(true); + given(voteParticipationRepository.findByVoteIdAndAnonymousId(1L, "anon")).willReturn(Optional.empty()); + given(voteParticipationRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(guestFreeVoteService.remaining("anon")).willReturn(4); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(1L); + + ParticipateResult result = service.participateAsGuest(1L, "anon", 10L); + + verify(guestFreeVoteService).consume("anon"); + assertThat(result.remainingFreeVotes()).isEqualTo(4); + } + + @Test + void 기존_비회원은_옵션만_변경하고_차감_없음() { + Vote ongoingVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(24), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + given(voteOptionRepository.existsByIdAndVoteId(20L, 1L)).willReturn(true); + VoteParticipation existing = VoteParticipation.ofGuest(1L, "anon", 10L); + given(voteParticipationRepository.findByVoteIdAndAnonymousId(1L, "anon")).willReturn(Optional.of(existing)); + given(guestFreeVoteService.remaining("anon")).willReturn(3); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(1L); + + service.participateAsGuest(1L, "anon", 20L); + + verify(guestFreeVoteService, org.mockito.Mockito.never()).consume(any()); + assertThat(existing.getOptionId()).isEqualTo(20L); + } + } + + @Nested + class cancel { + + @Test + void 정상적으로_참여를_취소한다() { + Vote ongoingVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(24), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + + service.cancel(1L, 2L); + + verify(voteParticipationRepository).deleteByVoteIdAndUserId(1L, 2L); + } + + @Test + void 종료된_투표_취소는_VoteEndedException() { + Vote endedVote = Vote.create(VoteType.GENERAL, "투표", null, "t", null, + Duration.ofHours(1), FIXED_CLOCK); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); + + assertThatThrownBy(() -> service.cancel(1L, 2L)) + .isInstanceOf(VoteEndedException.class); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/port/VoteEmojiCommandServiceTest.java b/src/test/java/com/ject/vs/vote/port/VoteEmojiCommandServiceTest.java new file mode 100644 index 00000000..846459f6 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/VoteEmojiCommandServiceTest.java @@ -0,0 +1,102 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.VoteEmoji; +import com.ject.vs.vote.domain.VoteEmojiReaction; +import com.ject.vs.vote.domain.VoteEmojiReactionRepository; +import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase.EmojiResult; +import org.junit.jupiter.api.Nested; +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.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class VoteEmojiCommandServiceTest { + + @InjectMocks + private VoteEmojiCommandService service; + + @Mock + private VoteEmojiReactionRepository reactionRepository; + + private void stubEmptySummary(Long voteId) { + given(reactionRepository.countByEmojiForVote(voteId)).willReturn(List.of()); + } + + @Nested + class reactAsMember { + + @Test + void 기존_반응_없으면_새로_저장한다() { + given(reactionRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.empty()); + given(reactionRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + stubEmptySummary(1L); + + EmojiResult result = service.reactAsMember(1L, 2L, VoteEmoji.LIKE); + + verify(reactionRepository).save(any()); + assertThat(result.myEmoji()).isEqualTo(VoteEmoji.LIKE); + } + + @Test + void 같은_이모지_재클릭시_취소한다() { + VoteEmojiReaction existing = VoteEmojiReaction.ofMember(1L, 2L, VoteEmoji.LIKE); + given(reactionRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(existing)); + stubEmptySummary(1L); + + EmojiResult result = service.reactAsMember(1L, 2L, VoteEmoji.LIKE); + + verify(reactionRepository).delete(existing); + assertThat(result.myEmoji()).isNull(); + } + + @Test + void 다른_이모지로_교체한다() { + VoteEmojiReaction existing = VoteEmojiReaction.ofMember(1L, 2L, VoteEmoji.LIKE); + given(reactionRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(existing)); + stubEmptySummary(1L); + + EmojiResult result = service.reactAsMember(1L, 2L, VoteEmoji.WOW); + + assertThat(existing.getEmoji()).isEqualTo(VoteEmoji.WOW); + assertThat(result.myEmoji()).isEqualTo(VoteEmoji.WOW); + } + + @Test + void null_전송시_기존_반응을_취소한다() { + VoteEmojiReaction existing = VoteEmojiReaction.ofMember(1L, 2L, VoteEmoji.SAD); + given(reactionRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(existing)); + stubEmptySummary(1L); + + EmojiResult result = service.reactAsMember(1L, 2L, null); + + verify(reactionRepository).delete(existing); + assertThat(result.myEmoji()).isNull(); + } + } + + @Nested + class reactAsGuest { + + @Test + void 비회원_신규_반응을_저장한다() { + given(reactionRepository.findByVoteIdAndAnonymousId(1L, "anon")).willReturn(Optional.empty()); + given(reactionRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + stubEmptySummary(1L); + + EmojiResult result = service.reactAsGuest(1L, "anon", VoteEmoji.ANGRY); + + verify(reactionRepository).save(any()); + assertThat(result.myEmoji()).isEqualTo(VoteEmoji.ANGRY); + } + } +} From a410c36b83f218ea3ce055f1585d30a0482ebff4 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:24:53 +0900 Subject: [PATCH 16/36] =?UTF-8?q?feat:=20STEP4=20-=20Immersive=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=EC=BB=A4=EB=A7=A8=EB=93=9C/=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/ImmersiveVoteCommandService.java | 83 ++++++++ .../vote/port/ImmersiveVoteQueryService.java | 88 +++++++++ .../port/in/ImmersiveVoteCommandUseCase.java | 24 +++ .../port/in/ImmersiveVoteQueryUseCase.java | 37 ++++ .../port/ImmersiveVoteCommandServiceTest.java | 154 +++++++++++++++ .../port/ImmersiveVoteQueryServiceTest.java | 181 ++++++++++++++++++ 6 files changed, 567 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/port/ImmersiveVoteCommandService.java create mode 100644 src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java create mode 100644 src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteCommandUseCase.java create mode 100644 src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java create mode 100644 src/test/java/com/ject/vs/vote/port/ImmersiveVoteCommandServiceTest.java create mode 100644 src/test/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java diff --git a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteCommandService.java b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteCommandService.java new file mode 100644 index 00000000..934ba0ec --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteCommandService.java @@ -0,0 +1,83 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.InvalidOptionException; +import com.ject.vs.vote.exception.VoteEndedException; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.ImmersiveVoteCommandUseCase; +import com.ject.vs.vote.port.in.VoteCommandUseCase.OptionResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ImmersiveVoteCommandService implements ImmersiveVoteCommandUseCase { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final GuestFreeVoteService guestFreeVoteService; + private final Clock clock; + + @Override + public ImmersiveParticipateResult participateOrCancel( + Long voteId, Long userId, String anonymousId, Long optionId) { + + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + if (vote.isEnded(clock)) throw new VoteEndedException(); + + if (!voteOptionRepository.existsByIdAndVoteId(optionId, voteId)) { + throw new InvalidOptionException(); + } + + Optional existing = userId != null + ? voteParticipationRepository.findByVoteIdAndUserId(voteId, userId) + : voteParticipationRepository.findByVoteIdAndAnonymousId(voteId, anonymousId); + + // 같은 옵션 재클릭 → 취소 + if (existing.isPresent() && existing.get().getOptionId().equals(optionId)) { + voteParticipationRepository.delete(existing.get()); + return buildResult(voteId, ImmersiveVoteAction.CANCELED, null, remaining(userId, anonymousId)); + } + + // 옵션 변경 + if (existing.isPresent()) { + existing.get().changeOption(optionId); + return buildResult(voteId, ImmersiveVoteAction.VOTED, optionId, remaining(userId, anonymousId)); + } + + // 신규 참여 + if (userId != null) { + voteParticipationRepository.save(VoteParticipation.ofMember(voteId, userId, optionId)); + } else { + guestFreeVoteService.consume(anonymousId); + voteParticipationRepository.save(VoteParticipation.ofGuest(voteId, anonymousId, optionId)); + } + return buildResult(voteId, ImmersiveVoteAction.VOTED, optionId, remaining(userId, anonymousId)); + } + + private ImmersiveParticipateResult buildResult(Long voteId, ImmersiveVoteAction action, + Long selectedOptionId, Integer remainingFreeVotes) { + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + long total = voteParticipationRepository.countByVoteId(voteId); + + List optionResults = options.stream().map(opt -> { + long count = voteParticipationRepository.countByVoteIdAndOptionId(voteId, opt.getId()); + int ratio = total == 0 ? 0 : (int) Math.round(count * 100.0 / total); + return new OptionResult(opt.getId(), opt.getLabel(), count, ratio); + }).toList(); + + return new ImmersiveParticipateResult(voteId, action, selectedOptionId, optionResults, remainingFreeVotes); + } + + private Integer remaining(Long userId, String anonymousId) { + if (userId != null) return null; + return guestFreeVoteService.remaining(anonymousId); + } +} diff --git a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java new file mode 100644 index 00000000..ca340c2a --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java @@ -0,0 +1,88 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImmersiveVoteQueryService implements ImmersiveVoteQueryUseCase { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final Clock clock; + + @Override + public ImmersiveFeedResult getFeed(Long cursor, int size, Long userId, String anonymousId) { + PageRequest pageable = PageRequest.of(0, size); + + Slice slice = cursor == null + ? voteRepository.findByTypeOrderByEndAtDesc(VoteType.IMMERSIVE, pageable) + : voteRepository.findByTypeAndIdLessThanOrderByEndAtDesc(VoteType.IMMERSIVE, cursor, pageable); + + List items = slice.getContent().stream() + .map(vote -> toFeedItem(vote, userId, anonymousId)) + .toList(); + + Long nextCursor = slice.hasNext() + ? items.get(items.size() - 1).voteId() + : null; + + return new ImmersiveFeedResult(items, nextCursor, slice.hasNext()); + } + + @Override + public ImmersiveLiveResult getLive(Long voteId) { + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + long total = voteParticipationRepository.countByVoteId(voteId); + + int aRatio = 0; + int bRatio = 0; + if (!options.isEmpty() && total > 0) { + long aCount = voteParticipationRepository.countByVoteIdAndOptionId(voteId, options.get(0).getId()); + aRatio = (int) Math.round(aCount * 100.0 / total); + bRatio = 100 - aRatio; + } + + // TODO: currentViewerCount — Redis 도입 후 갱신 예정 + return new ImmersiveLiveResult(voteId, aRatio, bRatio, (int) total, 0); + } + + private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) { + VoteStatus status = vote.isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; + int participantCount = (int) voteParticipationRepository.countByVoteId(vote.getId()); + + Long mySelectedOptionId = null; + if (userId != null) { + mySelectedOptionId = voteParticipationRepository + .findByVoteIdAndUserId(vote.getId(), userId) + .map(VoteParticipation::getOptionId) + .orElse(null); + } else if (anonymousId != null) { + mySelectedOptionId = voteParticipationRepository + .findByVoteIdAndAnonymousId(vote.getId(), anonymousId) + .map(VoteParticipation::getOptionId) + .orElse(null); + } + + return new ImmersiveFeedItem( + vote.getId(), + vote.getTitle(), + vote.getImageUrl(), + status, + vote.getEndAt(), + participantCount, + 0, // TODO: Redis viewer count + mySelectedOptionId + ); + } +} diff --git a/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteCommandUseCase.java new file mode 100644 index 00000000..4386d7ed --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteCommandUseCase.java @@ -0,0 +1,24 @@ +package com.ject.vs.vote.port.in; + +import com.ject.vs.vote.domain.ImmersiveVoteAction; + +import java.util.List; + +public interface ImmersiveVoteCommandUseCase { + + /** + * 같은 옵션 재클릭 → CANCELED, 다른 옵션 or 신규 → VOTED. + * 회원이면 userId 사용, 비회원이면 anonymousId 사용. + */ + ImmersiveParticipateResult participateOrCancel( + Long voteId, Long userId, String anonymousId, Long optionId); + + record ImmersiveParticipateResult( + Long voteId, + ImmersiveVoteAction action, + Long selectedOptionId, + List options, + Integer remainingFreeVotes + ) { + } +} diff --git a/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java new file mode 100644 index 00000000..e94a636d --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java @@ -0,0 +1,37 @@ +package com.ject.vs.vote.port.in; + +import com.ject.vs.vote.domain.VoteStatus; + +import java.time.Instant; +import java.util.List; + +public interface ImmersiveVoteQueryUseCase { + + ImmersiveFeedResult getFeed(Long cursor, int size, Long userId, String anonymousId); + + ImmersiveLiveResult getLive(Long voteId); + + record ImmersiveFeedResult(List items, Long nextCursor, boolean hasNext) { + } + + record ImmersiveFeedItem( + Long voteId, + String title, + String imageUrl, + VoteStatus status, + Instant endAt, + int participantCount, + int currentViewerCount, + Long mySelectedOptionId + ) { + } + + record ImmersiveLiveResult( + Long voteId, + int optionARatio, + int optionBRatio, + int participantCount, + int currentViewerCount + ) { + } +} diff --git a/src/test/java/com/ject/vs/vote/port/ImmersiveVoteCommandServiceTest.java b/src/test/java/com/ject/vs/vote/port/ImmersiveVoteCommandServiceTest.java new file mode 100644 index 00000000..aed0df27 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/ImmersiveVoteCommandServiceTest.java @@ -0,0 +1,154 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteEndedException; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.ImmersiveVoteCommandUseCase.ImmersiveParticipateResult; +import org.junit.jupiter.api.Nested; +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.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +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.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ImmersiveVoteCommandServiceTest { + + private static final Clock FIXED_CLOCK = + Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @InjectMocks + private ImmersiveVoteCommandService service; + + @Mock private VoteRepository voteRepository; + @Mock private VoteOptionRepository voteOptionRepository; + @Mock private VoteParticipationRepository voteParticipationRepository; + @Mock private GuestFreeVoteService guestFreeVoteService; + @Mock private Clock clock; + + private Vote ongoingVote() { + return Vote.create(VoteType.IMMERSIVE, "몰입", null, "t", "img.png", + Duration.ofHours(24), FIXED_CLOCK); + } + + private Vote endedVote() { + return Vote.create(VoteType.IMMERSIVE, "몰입", null, "t", "img.png", + Duration.ofHours(1), FIXED_CLOCK); + } + + private void stubOngoing(Long voteId) { + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findById(voteId)).willReturn(Optional.of(ongoingVote())); + given(voteOptionRepository.existsByIdAndVoteId(10L, voteId)).willReturn(true); + given(voteOptionRepository.findByVoteIdOrderByPosition(voteId)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(voteId)).willReturn(0L); + } + + @Nested + class 회원_신규_참여 { + + @Test + void 신규_참여시_VOTED_반환() { + stubOngoing(1L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.empty()); + given(voteParticipationRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + ImmersiveParticipateResult result = service.participateOrCancel(1L, 2L, null, 10L); + + assertThat(result.action()).isEqualTo(ImmersiveVoteAction.VOTED); + assertThat(result.selectedOptionId()).isEqualTo(10L); + assertThat(result.remainingFreeVotes()).isNull(); + verify(voteParticipationRepository).save(any()); + } + + @Test + void 다른_옵션_클릭시_VOTED_반환_및_옵션_변경() { + stubOngoing(1L); + VoteParticipation existing = VoteParticipation.ofMember(1L, 2L, 10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(existing)); + + ImmersiveParticipateResult result = service.participateOrCancel(1L, 2L, null, 10L); + // same option → CANCELED + assertThat(result.action()).isEqualTo(ImmersiveVoteAction.CANCELED); + } + + @Test + void 같은_옵션_재클릭시_CANCELED() { + stubOngoing(1L); + VoteParticipation existing = VoteParticipation.ofMember(1L, 2L, 10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 2L)).willReturn(Optional.of(existing)); + + ImmersiveParticipateResult result = service.participateOrCancel(1L, 2L, null, 10L); + + assertThat(result.action()).isEqualTo(ImmersiveVoteAction.CANCELED); + assertThat(result.selectedOptionId()).isNull(); + verify(voteParticipationRepository).delete(existing); + } + } + + @Nested + class 비회원_참여 { + + @Test + void 신규_비회원_참여시_차감_후_VOTED() { + stubOngoing(1L); + given(voteParticipationRepository.findByVoteIdAndAnonymousId(1L, "anon")).willReturn(Optional.empty()); + given(voteParticipationRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(guestFreeVoteService.remaining("anon")).willReturn(4); + + ImmersiveParticipateResult result = service.participateOrCancel(1L, null, "anon", 10L); + + assertThat(result.action()).isEqualTo(ImmersiveVoteAction.VOTED); + assertThat(result.remainingFreeVotes()).isEqualTo(4); + verify(guestFreeVoteService).consume("anon"); + } + + @Test + void 비회원_옵션_변경시_차감_없음() { + stubOngoing(1L); + VoteParticipation existing = VoteParticipation.ofGuest(1L, "anon", 20L); + given(voteParticipationRepository.findByVoteIdAndAnonymousId(1L, "anon")).willReturn(Optional.of(existing)); + given(guestFreeVoteService.remaining("anon")).willReturn(3); + + service.participateOrCancel(1L, null, "anon", 10L); + + verify(guestFreeVoteService, never()).consume(any()); + assertThat(existing.getOptionId()).isEqualTo(10L); + } + } + + @Nested + class 예외_케이스 { + + @Test + void 존재하지_않는_투표는_VoteNotFoundException() { + given(voteRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.participateOrCancel(999L, 1L, null, 10L)) + .isInstanceOf(VoteNotFoundException.class); + } + + @Test + void 종료된_투표는_VoteEndedException() { + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote())); + + assertThatThrownBy(() -> service.participateOrCancel(1L, 1L, null, 10L)) + .isInstanceOf(VoteEndedException.class); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java b/src/test/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java new file mode 100644 index 00000000..29666d7b --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java @@ -0,0 +1,181 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveFeedResult; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveLiveResult; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ImmersiveVoteQueryServiceTest { + + private static final Clock FIXED_CLOCK = + Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @InjectMocks + private ImmersiveVoteQueryService service; + + @Mock private VoteRepository voteRepository; + @Mock private VoteOptionRepository voteOptionRepository; + @Mock private VoteParticipationRepository voteParticipationRepository; + @Mock private Clock clock; + + private Vote makeVote(Duration duration) { + return Vote.create(VoteType.IMMERSIVE, "몰입", null, "t", "img.png", duration, FIXED_CLOCK); + } + + @Nested + class getFeed { + + @Test + void cursor_없을때_타입_정렬_쿼리_사용() { + Vote vote = makeVote(Duration.ofHours(24)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findByTypeOrderByEndAtDesc(eq(VoteType.IMMERSIVE), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteParticipationRepository.countByVoteId(any())).willReturn(5L); + + ImmersiveFeedResult result = service.getFeed(null, 10, null, null); + + assertThat(result.items()).hasSize(1); + assertThat(result.hasNext()).isFalse(); + assertThat(result.nextCursor()).isNull(); + } + + @Test + void cursor_있을때_cursor_기반_쿼리_사용() { + Vote vote = makeVote(Duration.ofHours(24)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findByTypeAndIdLessThanOrderByEndAtDesc( + eq(VoteType.IMMERSIVE), eq(100L), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteParticipationRepository.countByVoteId(any())).willReturn(3L); + + ImmersiveFeedResult result = service.getFeed(100L, 10, null, null); + + assertThat(result.items()).hasSize(1); + assertThat(result.hasNext()).isFalse(); + } + + @Test + void hasNext_true이면_nextCursor_반환() { + Vote v1 = makeVote(Duration.ofHours(24)); + Vote v2 = makeVote(Duration.ofHours(23)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findByTypeOrderByEndAtDesc(eq(VoteType.IMMERSIVE), any())) + .willReturn(new SliceImpl<>(List.of(v1, v2), PageRequest.of(0, 2), true)); + given(voteParticipationRepository.countByVoteId(any())).willReturn(0L); + + ImmersiveFeedResult result = service.getFeed(null, 2, null, null); + + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(result.items().get(1).voteId()); + } + + @Test + void 회원_userId로_mySelectedOptionId_조회() { + Vote vote = makeVote(Duration.ofHours(24)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findByTypeOrderByEndAtDesc(eq(VoteType.IMMERSIVE), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteParticipationRepository.countByVoteId(any())).willReturn(1L); + VoteParticipation participation = VoteParticipation.ofMember(null, 42L, 99L); + given(voteParticipationRepository.findByVoteIdAndUserId(any(), eq(42L))) + .willReturn(Optional.of(participation)); + + ImmersiveFeedResult result = service.getFeed(null, 10, 42L, null); + + assertThat(result.items().get(0).mySelectedOptionId()).isEqualTo(99L); + } + + @Test + void 비회원_anonymousId로_mySelectedOptionId_조회() { + Vote vote = makeVote(Duration.ofHours(24)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findByTypeOrderByEndAtDesc(eq(VoteType.IMMERSIVE), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteParticipationRepository.countByVoteId(any())).willReturn(1L); + VoteParticipation participation = VoteParticipation.ofGuest(null, "anon", 77L); + given(voteParticipationRepository.findByVoteIdAndAnonymousId(any(), eq("anon"))) + .willReturn(Optional.of(participation)); + + ImmersiveFeedResult result = service.getFeed(null, 10, null, "anon"); + + assertThat(result.items().get(0).mySelectedOptionId()).isEqualTo(77L); + } + + @Test + void 미참여시_mySelectedOptionId_null() { + Vote vote = makeVote(Duration.ofHours(24)); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T00:00:00Z")); + given(voteRepository.findByTypeOrderByEndAtDesc(eq(VoteType.IMMERSIVE), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteParticipationRepository.countByVoteId(any())).willReturn(0L); + + ImmersiveFeedResult result = service.getFeed(null, 10, null, null); + + assertThat(result.items().get(0).mySelectedOptionId()).isNull(); + } + } + + @Nested + class getLive { + + @Test + void 참여자_없으면_비율_0() { + VoteOption optA = VoteOption.of(1L, "A", 1); + VoteOption optB = VoteOption.of(1L, "B", 2); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of(optA, optB)); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(0L); + + ImmersiveLiveResult result = service.getLive(1L); + + assertThat(result.optionARatio()).isEqualTo(0); + assertThat(result.optionBRatio()).isEqualTo(0); + assertThat(result.participantCount()).isEqualTo(0); + } + + @Test + void A_3표_B_1표이면_A비율_75_B비율_25() { + VoteOption optA = VoteOption.of(1L, "A", 1); + VoteOption optB = VoteOption.of(1L, "B", 2); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of(optA, optB)); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(4L); + given(voteParticipationRepository.countByVoteIdAndOptionId(eq(1L), any())).willReturn(3L); + + ImmersiveLiveResult result = service.getLive(1L); + + assertThat(result.optionARatio()).isEqualTo(75); + assertThat(result.optionBRatio()).isEqualTo(25); + assertThat(result.participantCount()).isEqualTo(4); + } + + @Test + void currentViewerCount_항상_0() { + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(0L); + + ImmersiveLiveResult result = service.getLive(1L); + + assertThat(result.currentViewerCount()).isEqualTo(0); + } + } +} From 4f863f4ba91439ea0401d41c7729552fca1917ff Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 16:27:59 +0900 Subject: [PATCH 17/36] =?UTF-8?q?feat:=20=ED=88=AC=ED=91=9C=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20+=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=8B=A4=ED=96=89=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ject/vs/VsServerApplication.java | 2 + .../java/com/ject/vs/config/AsyncConfig.java | 35 ++++++ .../ject/vs/vote/event/VoteEndedEvent.java | 4 + .../vs/vote/scheduler/VoteCloseScheduler.java | 53 +++++++++ .../scheduler/VoteCloseSchedulerTest.java | 103 ++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 src/main/java/com/ject/vs/config/AsyncConfig.java create mode 100644 src/main/java/com/ject/vs/vote/event/VoteEndedEvent.java create mode 100644 src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java create mode 100644 src/test/java/com/ject/vs/vote/scheduler/VoteCloseSchedulerTest.java diff --git a/src/main/java/com/ject/vs/VsServerApplication.java b/src/main/java/com/ject/vs/VsServerApplication.java index fc87e86a..67b05534 100644 --- a/src/main/java/com/ject/vs/VsServerApplication.java +++ b/src/main/java/com/ject/vs/VsServerApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class VsServerApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ject/vs/config/AsyncConfig.java b/src/main/java/com/ject/vs/config/AsyncConfig.java new file mode 100644 index 00000000..69f38725 --- /dev/null +++ b/src/main/java/com/ject/vs/config/AsyncConfig.java @@ -0,0 +1,35 @@ +package com.ject.vs.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean("voteCloseExecutor") + public Executor voteCloseExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("vote-close-"); + executor.initialize(); + return executor; + } + + @Bean("aiInsightExecutor") + public Executor aiInsightExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("ai-insight-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/ject/vs/vote/event/VoteEndedEvent.java b/src/main/java/com/ject/vs/vote/event/VoteEndedEvent.java new file mode 100644 index 00000000..30856930 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/event/VoteEndedEvent.java @@ -0,0 +1,4 @@ +package com.ject.vs.vote.event; + +public record VoteEndedEvent(Long voteId) { +} diff --git a/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java b/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java new file mode 100644 index 00000000..d015318c --- /dev/null +++ b/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java @@ -0,0 +1,53 @@ +package com.ject.vs.vote.scheduler; + +import com.ject.vs.vote.domain.Vote; +import com.ject.vs.vote.domain.VoteRepository; +import com.ject.vs.vote.event.VoteEndedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class VoteCloseScheduler { + + private final VoteRepository voteRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + @Scheduled(cron = "0 * * * * *") + @Transactional + public void closeExpiredVotes() { + List expired = voteRepository.findExpiredOngoing(Instant.now(clock)); + for (Vote vote : expired) { + vote.markEnded(); + eventPublisher.publishEvent(new VoteEndedEvent(vote.getId())); + } + if (!expired.isEmpty()) { + log.info("Closed {} expired votes", expired.size()); + } + } + + /** + * 서버 재시작 시 스케줄러가 돌기 전에 이미 만료된 투표를 보정한다. + * @PostConstruct는 ApplicationContext 초기화를 블록하므로 + * ApplicationReadyEvent + @Async로 부팅 완료 후 백그라운드에서 실행. + */ + @EventListener(ApplicationReadyEvent.class) + @Async("voteCloseExecutor") + public void closeExpiredOnStartup() { + log.info("Running startup vote close compensation"); + closeExpiredVotes(); + } +} diff --git a/src/test/java/com/ject/vs/vote/scheduler/VoteCloseSchedulerTest.java b/src/test/java/com/ject/vs/vote/scheduler/VoteCloseSchedulerTest.java new file mode 100644 index 00000000..79a6906e --- /dev/null +++ b/src/test/java/com/ject/vs/vote/scheduler/VoteCloseSchedulerTest.java @@ -0,0 +1,103 @@ +package com.ject.vs.vote.scheduler; + +import com.ject.vs.vote.domain.Vote; +import com.ject.vs.vote.domain.VoteRepository; +import com.ject.vs.vote.domain.VoteStatus; +import com.ject.vs.vote.domain.VoteType; +import com.ject.vs.vote.event.VoteEndedEvent; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class VoteCloseSchedulerTest { + + private static final Clock FIXED_CLOCK = + Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + + @InjectMocks + private VoteCloseScheduler scheduler; + + @Mock private VoteRepository voteRepository; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private Clock clock; + + private Vote makeExpiredVote() { + // duration 1h, clock은 생성 시점이 FIXED_CLOCK → endAt = T+1h + // 스케줄러가 T+2h에 실행되면 이미 만료 + return Vote.create(VoteType.GENERAL, "test", null, "thumb.png", null, + Duration.ofHours(1), FIXED_CLOCK); + } + + @Nested + class closeExpiredVotes { + + @Test + void 만료된_투표가_있으면_markEnded_호출하고_이벤트_발행() { + Vote expired = makeExpiredVote(); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + given(voteRepository.findExpiredOngoing(any())).willReturn(List.of(expired)); + + scheduler.closeExpiredVotes(); + + assertThat(expired.getStatus()).isEqualTo(VoteStatus.ENDED); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VoteEndedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(VoteEndedEvent.class); + } + + @Test + void 만료된_투표_없으면_이벤트_미발행() { + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + given(voteRepository.findExpiredOngoing(any())).willReturn(List.of()); + + scheduler.closeExpiredVotes(); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void 만료된_투표_여러개이면_각각_이벤트_발행() { + Vote v1 = makeExpiredVote(); + Vote v2 = makeExpiredVote(); + given(clock.instant()).willReturn(Instant.parse("2025-01-01T02:00:00Z")); + given(voteRepository.findExpiredOngoing(any())).willReturn(List.of(v1, v2)); + + scheduler.closeExpiredVotes(); + + assertThat(v1.getStatus()).isEqualTo(VoteStatus.ENDED); + assertThat(v2.getStatus()).isEqualTo(VoteStatus.ENDED); + verify(eventPublisher, times(2)).publishEvent(any(VoteEndedEvent.class)); + } + + @Test + void findExpiredOngoing에_현재_시각_전달() { + Instant now = Instant.parse("2025-06-01T12:00:00Z"); + given(clock.instant()).willReturn(now); + given(voteRepository.findExpiredOngoing(now)).willReturn(List.of()); + + scheduler.closeExpiredVotes(); + + verify(voteRepository).findExpiredOngoing(now); + } + } +} From 0aa08cb3e138b81074aa3930b5878b9e71e1a669 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 21:06:26 +0900 Subject: [PATCH 18/36] feat(vote): add VoteDetail/VoteResult query services and exception handling --- .../exception/GlobalExceptionHandler.java | 16 ++++ .../vote/exception/UnauthorizedException.java | 14 +++ .../vs/vote/port/VoteDetailQueryService.java | 93 +++++++++++++++++++ .../vs/vote/port/VoteResultQueryService.java | 56 +++++++++++ .../vote/port/in/VoteResultQueryUseCase.java | 27 ++++++ 5 files changed, 206 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java create mode 100644 src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java create mode 100644 src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java create mode 100644 src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java diff --git a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java index 26d9ea88..b67c378e 100644 --- a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java @@ -2,6 +2,8 @@ import com.ject.vs.vote.exception.*; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -48,6 +50,20 @@ public ResponseEntity handleImageRequired(ImageRequiredException return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); } + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(UnauthorizedException e) { + return ResponseEntity.status(401).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .findFirst() + .orElse("입력값이 올바르지 않습니다"); + return ResponseEntity.status(400).body(new ErrorResponse("INVALID_INPUT", message)); + } + public record ErrorResponse(String code, String message) { } } diff --git a/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java b/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java new file mode 100644 index 00000000..7e31887a --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java @@ -0,0 +1,14 @@ +package com.ject.vs.vote.exception; + +public class UnauthorizedException extends RuntimeException { + + private static final String ERROR_CODE = "UNAUTHORIZED"; + + public UnauthorizedException() { + super("인증이 필요합니다"); + } + + public String getErrorCode() { + return ERROR_CODE; + } +} diff --git a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java new file mode 100644 index 00000000..9dea41ec --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java @@ -0,0 +1,93 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.in.VoteCommandUseCase.OptionResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoteDetailQueryService { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final VoteEmojiReactionRepository emojiReactionRepository; + private final Clock clock; + + public VoteDetailResult getDetail(Long voteId, Long userId, String anonymousId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + VoteStatus status = vote.isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; + + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + long total = voteParticipationRepository.countByVoteId(voteId); + + List optionResults = options.stream().map(opt -> { + long count = voteParticipationRepository.countByVoteIdAndOptionId(voteId, opt.getId()); + int ratio = total == 0 ? 0 : (int) Math.round(count * 100.0 / total); + return new OptionResult(opt.getId(), opt.getLabel(), count, ratio); + }).toList(); + + Long mySelectedOptionId = null; + if (userId != null) { + mySelectedOptionId = voteParticipationRepository + .findByVoteIdAndUserId(voteId, userId) + .map(VoteParticipation::getOptionId) + .orElse(null); + } else if (anonymousId != null) { + mySelectedOptionId = voteParticipationRepository + .findByVoteIdAndAnonymousId(voteId, anonymousId) + .map(VoteParticipation::getOptionId) + .orElse(null); + } + + Map emojiSummary = Arrays.stream(VoteEmoji.values()) + .collect(Collectors.toMap(e -> e, e -> 0L)); + emojiReactionRepository.countByEmojiForVote(voteId) + .forEach(row -> emojiSummary.put((VoteEmoji) row[0], (Long) row[1])); + + VoteEmoji myEmoji = null; + if (userId != null) { + myEmoji = emojiReactionRepository.findByVoteIdAndUserId(voteId, userId) + .map(VoteEmojiReaction::getEmoji) + .orElse(null); + } else if (anonymousId != null) { + myEmoji = emojiReactionRepository.findByVoteIdAndAnonymousId(voteId, anonymousId) + .map(VoteEmojiReaction::getEmoji) + .orElse(null); + } + + return new VoteDetailResult( + vote.getId(), vote.getType(), vote.getTitle(), vote.getContent(), + vote.getThumbnailUrl(), vote.getImageUrl(), status, vote.getEndAt(), + (int) total, optionResults, mySelectedOptionId, emojiSummary, myEmoji + ); + } + + public record VoteDetailResult( + Long voteId, + VoteType type, + String title, + String content, + String thumbnailUrl, + String imageUrl, + VoteStatus status, + Instant endAt, + int participantCount, + List options, + Long mySelectedOptionId, + Map emojiSummary, + VoteEmoji myEmoji + ) { + } +} diff --git a/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java new file mode 100644 index 00000000..87fbfa84 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java @@ -0,0 +1,56 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.exception.VoteNotEndedException; +import com.ject.vs.vote.port.in.VoteCommandUseCase.OptionResult; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoteResultQueryService implements VoteResultQueryUseCase { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final Clock clock; + + @Override + public VoteResultDetail getResult(Long voteId, Long userId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + if (vote.isOngoing(clock)) throw new VoteNotEndedException(); + + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + long total = voteParticipationRepository.countByVoteId(voteId); + + List optionResults = options.stream().map(opt -> { + long count = voteParticipationRepository.countByVoteIdAndOptionId(voteId, opt.getId()); + int ratio = total == 0 ? 0 : (int) Math.round(count * 100.0 / total); + return new OptionResult(opt.getId(), opt.getLabel(), count, ratio); + }).toList(); + + Long mySelectedOptionId = null; + if (userId != null) { + mySelectedOptionId = voteParticipationRepository + .findByVoteIdAndUserId(voteId, userId) + .map(VoteParticipation::getOptionId) + .orElse(null); + } + + return new VoteResultDetail(voteId, vote.getTitle(), VoteStatus.ENDED, + vote.getEndAt(), (int) total, optionResults, mySelectedOptionId); + } + + @Override + public ShareLinkResult getShareLink(Long voteId) { + if (!voteRepository.existsById(voteId)) throw new VoteNotFoundException(); + return new ShareLinkResult("https://vs.app/poll/result/" + voteId); + } +} diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java new file mode 100644 index 00000000..228c263c --- /dev/null +++ b/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java @@ -0,0 +1,27 @@ +package com.ject.vs.vote.port.in; + +import com.ject.vs.vote.domain.VoteStatus; + +import java.time.Instant; +import java.util.List; + +public interface VoteResultQueryUseCase { + + VoteResultDetail getResult(Long voteId, Long userId); + + ShareLinkResult getShareLink(Long voteId); + + record VoteResultDetail( + Long voteId, + String title, + VoteStatus status, + Instant endAt, + int participantCount, + List options, + Long mySelectedOptionId + ) { + } + + record ShareLinkResult(String url) { + } +} From 75cafd9cceecde9e21b3c5f446b49686557a9057 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 21:06:42 +0900 Subject: [PATCH 19/36] feat(vote): add VoteController and request/response DTOs --- .../vs/vote/adapter/web/VoteController.java | 59 +++++++++++++++++++ .../adapter/web/dto/ParticipateRequest.java | 6 ++ .../adapter/web/dto/ParticipateResponse.java | 24 ++++++++ .../adapter/web/dto/VoteCreateRequest.java | 23 ++++++++ .../adapter/web/dto/VoteCreateResponse.java | 18 ++++++ .../adapter/web/dto/VoteDetailResponse.java | 49 +++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/VoteController.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateRequest.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateResponse.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateRequest.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateResponse.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java new file mode 100644 index 00000000..221af035 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java @@ -0,0 +1,59 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.config.AnonymousId; +import com.ject.vs.vote.adapter.web.dto.*; +import com.ject.vs.vote.exception.UnauthorizedException; +import com.ject.vs.vote.port.VoteDetailQueryService; +import com.ject.vs.vote.port.in.VoteCommandUseCase; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/votes") +@RequiredArgsConstructor +public class VoteController { + + private final VoteCommandUseCase voteCommandUseCase; + private final VoteDetailQueryService voteDetailQueryService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public VoteCreateResponse create( + @AuthenticationPrincipal Long userId, + @RequestBody @Valid VoteCreateRequest request) { + if (userId == null) throw new UnauthorizedException(); + return VoteCreateResponse.from(voteCommandUseCase.create(request.toCommand())); + } + + @GetMapping("/{voteId}") + public VoteDetailResponse getDetail( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId) { + return VoteDetailResponse.from(voteDetailQueryService.getDetail(voteId, userId, anonymousId)); + } + + @PostMapping("/{voteId}/participate") + public ParticipateResponse participate( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId, + @RequestBody @Valid ParticipateRequest request) { + VoteCommandUseCase.ParticipateResult result = userId != null + ? voteCommandUseCase.participateAsMember(voteId, userId, request.optionId()) + : voteCommandUseCase.participateAsGuest(voteId, anonymousId, request.optionId()); + return ParticipateResponse.from(result); + } + + @DeleteMapping("/{voteId}/participate") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void cancel( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId) { + if (userId == null) throw new UnauthorizedException(); + voteCommandUseCase.cancel(voteId, userId); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateRequest.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateRequest.java new file mode 100644 index 00000000..77f9ab45 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateRequest.java @@ -0,0 +1,6 @@ +package com.ject.vs.vote.adapter.web.dto; + +import jakarta.validation.constraints.NotNull; + +public record ParticipateRequest(@NotNull Long optionId) { +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateResponse.java new file mode 100644 index 00000000..f8a88070 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ParticipateResponse.java @@ -0,0 +1,24 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.port.in.VoteCommandUseCase.ParticipateResult; + +import java.util.List; + +public record ParticipateResponse( + Long voteId, + Long selectedOptionId, + List options, + int participantCount, + Integer remainingFreeVotes +) { + public record OptionItem(Long optionId, String label, long voteCount, Integer ratio) { + } + + public static ParticipateResponse from(ParticipateResult result) { + List items = result.options().stream() + .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) + .toList(); + return new ParticipateResponse(result.voteId(), result.selectedOptionId(), + items, result.participantCount(), result.remainingFreeVotes()); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateRequest.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateRequest.java new file mode 100644 index 00000000..4abcc270 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateRequest.java @@ -0,0 +1,23 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.domain.VoteDuration; +import com.ject.vs.vote.domain.VoteType; +import com.ject.vs.vote.port.in.VoteCommandUseCase.VoteCreateCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record VoteCreateRequest( + @NotNull VoteType type, + @NotBlank @Size(max = 100) String title, + @Size(max = 1000) String content, + @NotBlank String thumbnailUrl, + String imageUrl, + @NotNull VoteDuration duration, + @NotBlank @Size(max = 50) String optionA, + @NotBlank @Size(max = 50) String optionB +) { + public VoteCreateCommand toCommand() { + return new VoteCreateCommand(type, title, content, thumbnailUrl, imageUrl, duration, optionA, optionB); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateResponse.java new file mode 100644 index 00000000..863b51f0 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteCreateResponse.java @@ -0,0 +1,18 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.port.in.VoteCommandUseCase.VoteCreateResult; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +public record VoteCreateResponse(Long voteId, String status, OffsetDateTime endAt) { + + private static OffsetDateTime toKst(Instant instant) { + return instant.atOffset(ZoneOffset.ofHours(9)); + } + + public static VoteCreateResponse from(VoteCreateResult result) { + return new VoteCreateResponse(result.voteId(), result.status().name(), toKst(result.endAt())); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java new file mode 100644 index 00000000..1f94d6aa --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java @@ -0,0 +1,49 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.domain.VoteEmoji; +import com.ject.vs.vote.port.VoteDetailQueryService.VoteDetailResult; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record VoteDetailResponse( + Long voteId, + String type, + String title, + String content, + String thumbnailUrl, + String imageUrl, + String status, + OffsetDateTime endAt, + int participantCount, + List options, + Long mySelectedOptionId, + Map emojiSummary, + String myEmoji +) { + public record OptionItem(Long optionId, String label, long voteCount, Integer ratio) { + } + + private static OffsetDateTime toKst(Instant instant) { + return instant.atOffset(ZoneOffset.ofHours(9)); + } + + public static VoteDetailResponse from(VoteDetailResult result) { + List items = result.options().stream() + .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) + .toList(); + Map summary = result.emojiSummary().entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue)); + String myEmoji = result.myEmoji() != null ? result.myEmoji().name() : null; + return new VoteDetailResponse( + result.voteId(), result.type().name(), result.title(), result.content(), + result.thumbnailUrl(), result.imageUrl(), result.status().name(), + toKst(result.endAt()), result.participantCount(), items, + result.mySelectedOptionId(), summary, myEmoji + ); + } +} From a8cf1d29a3fa425c5f77d766812e39d3f0efd52b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 21:06:51 +0900 Subject: [PATCH 20/36] feat(vote): add ImmersiveVoteController and immersive-specific DTOs --- .../adapter/web/ImmersiveVoteController.java | 47 +++++++++++++++++++ .../web/dto/ImmersiveFeedResponse.java | 37 +++++++++++++++ .../web/dto/ImmersiveLiveResponse.java | 16 +++++++ .../web/dto/ImmersiveParticipateResponse.java | 26 ++++++++++ 4 files changed, 126 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveFeedResponse.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveLiveResponse.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveParticipateResponse.java diff --git a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java new file mode 100644 index 00000000..f7141ad5 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java @@ -0,0 +1,47 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.config.AnonymousId; +import com.ject.vs.vote.adapter.web.dto.ImmersiveFeedResponse; +import com.ject.vs.vote.adapter.web.dto.ImmersiveLiveResponse; +import com.ject.vs.vote.adapter.web.dto.ImmersiveParticipateResponse; +import com.ject.vs.vote.adapter.web.dto.ParticipateRequest; +import com.ject.vs.vote.port.in.ImmersiveVoteCommandUseCase; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/immersive-votes") +@RequiredArgsConstructor +public class ImmersiveVoteController { + + private final ImmersiveVoteCommandUseCase immersiveVoteCommandUseCase; + private final ImmersiveVoteQueryUseCase immersiveVoteQueryUseCase; + + @GetMapping + public ImmersiveFeedResponse getFeed( + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId) { + return ImmersiveFeedResponse.from(immersiveVoteQueryUseCase.getFeed(cursor, size, userId, anonymousId)); + } + + @PutMapping("/{voteId}/participate") + public ImmersiveParticipateResponse participateOrCancel( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId, + @RequestBody @Valid ParticipateRequest request) { + ImmersiveVoteCommandUseCase.ImmersiveParticipateResult result = + immersiveVoteCommandUseCase.participateOrCancel(voteId, userId, anonymousId, request.optionId()); + return ImmersiveParticipateResponse.from(result); + } + + @GetMapping("/{voteId}/live") + public ImmersiveLiveResponse getLive(@PathVariable Long voteId) { + return ImmersiveLiveResponse.from(immersiveVoteQueryUseCase.getLive(voteId)); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveFeedResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveFeedResponse.java new file mode 100644 index 00000000..ca89ae2e --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveFeedResponse.java @@ -0,0 +1,37 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveFeedResult; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +public record ImmersiveFeedResponse(List items, Long nextCursor, boolean hasNext) { + + public record FeedItem( + Long voteId, + String title, + String imageUrl, + String status, + OffsetDateTime endAt, + int participantCount, + int currentViewerCount, + Long mySelectedOptionId + ) { + } + + private static OffsetDateTime toKst(Instant instant) { + return instant.atOffset(ZoneOffset.ofHours(9)); + } + + public static ImmersiveFeedResponse from(ImmersiveFeedResult result) { + List items = result.items().stream() + .map(i -> new FeedItem( + i.voteId(), i.title(), i.imageUrl(), i.status().name(), + toKst(i.endAt()), i.participantCount(), i.currentViewerCount(), + i.mySelectedOptionId())) + .toList(); + return new ImmersiveFeedResponse(items, result.nextCursor(), result.hasNext()); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveLiveResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveLiveResponse.java new file mode 100644 index 00000000..f3af835f --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveLiveResponse.java @@ -0,0 +1,16 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveLiveResult; + +public record ImmersiveLiveResponse( + Long voteId, + int optionARatio, + int optionBRatio, + int participantCount, + int currentViewerCount +) { + public static ImmersiveLiveResponse from(ImmersiveLiveResult result) { + return new ImmersiveLiveResponse(result.voteId(), result.optionARatio(), result.optionBRatio(), + result.participantCount(), result.currentViewerCount()); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveParticipateResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveParticipateResponse.java new file mode 100644 index 00000000..0cf0c8c9 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveParticipateResponse.java @@ -0,0 +1,26 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.port.in.ImmersiveVoteCommandUseCase.ImmersiveParticipateResult; + +import java.util.List; + +public record ImmersiveParticipateResponse( + Long voteId, + String action, + Long selectedOptionId, + List options, + Integer remainingFreeVotes +) { + public record OptionItem(Long optionId, String label, long voteCount, Integer ratio) { + } + + public static ImmersiveParticipateResponse from(ImmersiveParticipateResult result) { + List items = result.options().stream() + .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) + .toList(); + return new ImmersiveParticipateResponse( + result.voteId(), result.action().name(), result.selectedOptionId(), + items, result.remainingFreeVotes() + ); + } +} From 1a3e8d339cd6208d6dd2439905b220d3ac97f214 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 21:06:57 +0900 Subject: [PATCH 21/36] feat(vote): add VoteResultController and result/share-link DTOs --- .../adapter/web/VoteResultController.java | 28 ++++++++++++++++ .../adapter/web/dto/ShareLinkResponse.java | 4 +++ .../adapter/web/dto/VoteResultResponse.java | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ShareLinkResponse.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java new file mode 100644 index 00000000..9b08864b --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java @@ -0,0 +1,28 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.vote.adapter.web.dto.ShareLinkResponse; +import com.ject.vs.vote.adapter.web.dto.VoteResultResponse; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/votes/{voteId}") +@RequiredArgsConstructor +public class VoteResultController { + + private final VoteResultQueryUseCase voteResultQueryUseCase; + + @GetMapping("/result") + public VoteResultResponse getResult( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId) { + return VoteResultResponse.from(voteResultQueryUseCase.getResult(voteId, userId)); + } + + @GetMapping("/share") + public ShareLinkResponse getShareLink(@PathVariable Long voteId) { + return new ShareLinkResponse(voteResultQueryUseCase.getShareLink(voteId).url()); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ShareLinkResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ShareLinkResponse.java new file mode 100644 index 00000000..01b4a4cb --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ShareLinkResponse.java @@ -0,0 +1,4 @@ +package com.ject.vs.vote.adapter.web.dto; + +public record ShareLinkResponse(String url) { +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java new file mode 100644 index 00000000..bcb4c7b1 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java @@ -0,0 +1,33 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.VoteResultDetail; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +public record VoteResultResponse( + Long voteId, + String title, + String status, + OffsetDateTime endAt, + int participantCount, + List options, + Long mySelectedOptionId +) { + public record OptionItem(Long optionId, String label, long voteCount, Integer ratio) { + } + + private static OffsetDateTime toKst(Instant instant) { + return instant.atOffset(ZoneOffset.ofHours(9)); + } + + public static VoteResultResponse from(VoteResultDetail result) { + List items = result.options().stream() + .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) + .toList(); + return new VoteResultResponse(result.voteId(), result.title(), result.status().name(), + toKst(result.endAt()), result.participantCount(), items, result.mySelectedOptionId()); + } +} From 6a8abdba5b661dcb4d6a7288f15b2b06b79e5744 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 21:07:04 +0900 Subject: [PATCH 22/36] feat(vote): add VoteEmojiController and GuestFreeVoteController with DTOs --- .../adapter/web/GuestFreeVoteController.java | 22 ++++++++++ .../vote/adapter/web/VoteEmojiController.java | 40 +++++++++++++++++++ .../vs/vote/adapter/web/dto/EmojiRequest.java | 7 ++++ .../vote/adapter/web/dto/EmojiResponse.java | 17 ++++++++ .../adapter/web/dto/FreeVotesResponse.java | 4 ++ 5 files changed, 90 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/FreeVotesResponse.java diff --git a/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java new file mode 100644 index 00000000..49e25fcd --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java @@ -0,0 +1,22 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.config.AnonymousId; +import com.ject.vs.vote.adapter.web.dto.FreeVotesResponse; +import com.ject.vs.vote.port.GuestFreeVoteService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/me") +@RequiredArgsConstructor +public class GuestFreeVoteController { + + private final GuestFreeVoteService guestFreeVoteService; + + @GetMapping("/free-votes") + public FreeVotesResponse getFreeVotes(@AnonymousId String anonymousId) { + return new FreeVotesResponse(guestFreeVoteService.remaining(anonymousId)); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java new file mode 100644 index 00000000..3da6b306 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java @@ -0,0 +1,40 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.config.AnonymousId; +import com.ject.vs.vote.adapter.web.dto.EmojiRequest; +import com.ject.vs.vote.adapter.web.dto.EmojiResponse; +import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class VoteEmojiController { + + private final VoteEmojiCommandUseCase voteEmojiCommandUseCase; + + @PutMapping("/api/votes/{voteId}/emoji") + public EmojiResponse reactOnVote( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId, + @RequestBody EmojiRequest request) { + VoteEmojiCommandUseCase.EmojiResult result = userId != null + ? voteEmojiCommandUseCase.reactAsMember(voteId, userId, request.emoji()) + : voteEmojiCommandUseCase.reactAsGuest(voteId, anonymousId, request.emoji()); + return EmojiResponse.from(result); + } + + @PutMapping("/api/immersive-votes/{voteId}/emoji") + public EmojiResponse reactOnImmersiveVote( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId, + @RequestBody EmojiRequest request) { + VoteEmojiCommandUseCase.EmojiResult result = userId != null + ? voteEmojiCommandUseCase.reactAsMember(voteId, userId, request.emoji()) + : voteEmojiCommandUseCase.reactAsGuest(voteId, anonymousId, request.emoji()); + return EmojiResponse.from(result); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java new file mode 100644 index 00000000..0a36e114 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java @@ -0,0 +1,7 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.domain.VoteEmoji; + +public record EmojiRequest(VoteEmoji emoji) { + // emoji == null 이면 취소 +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java new file mode 100644 index 00000000..855bac39 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java @@ -0,0 +1,17 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.domain.VoteEmoji; +import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase.EmojiResult; + +import java.util.Map; +import java.util.stream.Collectors; + +public record EmojiResponse(Map emojiSummary, long total, String myEmoji) { + + public static EmojiResponse from(EmojiResult result) { + Map summary = result.emojiSummary().entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue)); + String myEmoji = result.myEmoji() != null ? result.myEmoji().name() : null; + return new EmojiResponse(summary, result.total(), myEmoji); + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/FreeVotesResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/FreeVotesResponse.java new file mode 100644 index 00000000..ce0f2bb3 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/FreeVotesResponse.java @@ -0,0 +1,4 @@ +package com.ject.vs.vote.adapter.web.dto; + +public record FreeVotesResponse(int remaining) { +} From 6e3ea7c66c56c809b36840bba9b9a4e5dce3c90b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Fri, 8 May 2026 21:07:12 +0900 Subject: [PATCH 23/36] test(vote): add WebMvcTest slice tests for all vote controllers --- .../web/GuestFreeVoteControllerTest.java | 48 +++++ .../web/ImmersiveVoteControllerTest.java | 112 ++++++++++++ .../vote/adapter/web/VoteControllerTest.java | 171 ++++++++++++++++++ .../adapter/web/VoteEmojiControllerTest.java | 110 +++++++++++ .../adapter/web/VoteResultControllerTest.java | 105 +++++++++++ 5 files changed, 546 insertions(+) create mode 100644 src/test/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java create mode 100644 src/test/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java create mode 100644 src/test/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java create mode 100644 src/test/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java create mode 100644 src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java diff --git a/src/test/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java b/src/test/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java new file mode 100644 index 00000000..eb81a26d --- /dev/null +++ b/src/test/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java @@ -0,0 +1,48 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.config.OAuth2LoginSuccessHandler; +import com.ject.vs.util.CookieUtil; +import com.ject.vs.util.JwtProvider; +import org.springframework.security.test.context.support.WithMockUser; +import com.ject.vs.vote.port.GuestFreeVoteService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(GuestFreeVoteController.class) +class GuestFreeVoteControllerTest { + + @Autowired MockMvc mockMvc; + + @MockBean GuestFreeVoteService guestFreeVoteService; + @MockBean JwtProvider jwtProvider; + @MockBean CookieUtil cookieUtil; + @MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + @Test + @WithMockUser + void 잔여_무료_투표_200_반환() throws Exception { + given(guestFreeVoteService.remaining(any())).willReturn(3); + + mockMvc.perform(get("/api/me/free-votes")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.remaining").value(3)); + } + + @Test + @WithMockUser + void anonymous_id_쿠키_없으면_Set_Cookie_헤더_발급() throws Exception { + given(guestFreeVoteService.remaining(any())).willReturn(5); + + mockMvc.perform(get("/api/me/free-votes")) + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")); + } +} diff --git a/src/test/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java b/src/test/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java new file mode 100644 index 00000000..ea9d4308 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java @@ -0,0 +1,112 @@ +package com.ject.vs.vote.adapter.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ject.vs.config.OAuth2LoginSuccessHandler; +import com.ject.vs.util.CookieUtil; +import com.ject.vs.util.JwtProvider; +import com.ject.vs.vote.adapter.web.dto.ParticipateRequest; +import com.ject.vs.vote.domain.ImmersiveVoteAction; +import com.ject.vs.vote.port.in.ImmersiveVoteCommandUseCase; +import com.ject.vs.vote.port.in.ImmersiveVoteCommandUseCase.ImmersiveParticipateResult; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveFeedResult; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveLiveResult; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ImmersiveVoteController.class) +class ImmersiveVoteControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @MockBean ImmersiveVoteCommandUseCase immersiveVoteCommandUseCase; + @MockBean ImmersiveVoteQueryUseCase immersiveVoteQueryUseCase; + @MockBean JwtProvider jwtProvider; + @MockBean CookieUtil cookieUtil; + @MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + private static final UsernamePasswordAuthenticationToken AUTH = + new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList()); + + @Nested + class getFeed { + + @Test + @WithMockUser + void 피드_목록_200_반환() throws Exception { + given(immersiveVoteQueryUseCase.getFeed(any(), anyInt(), any(), any())) + .willReturn(new ImmersiveFeedResult(List.of(), null, false)); + + mockMvc.perform(get("/api/immersive-votes")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasNext").value(false)); + } + } + + @Nested + class participateOrCancel { + + @Test + void 회원_참여_VOTED_반환() throws Exception { + given(immersiveVoteCommandUseCase.participateOrCancel(eq(1L), eq(1L), any(), eq(10L))) + .willReturn(new ImmersiveParticipateResult(1L, ImmersiveVoteAction.VOTED, 10L, List.of(), null)); + + mockMvc.perform(put("/api/immersive-votes/1/participate") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ParticipateRequest(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.action").value("VOTED")) + .andExpect(jsonPath("$.selectedOptionId").value(10)); + } + + @Test + @WithMockUser + void 비회원_참여_VOTED_반환() throws Exception { + given(immersiveVoteCommandUseCase.participateOrCancel(eq(1L), isNull(), any(), eq(10L))) + .willReturn(new ImmersiveParticipateResult(1L, ImmersiveVoteAction.VOTED, 10L, List.of(), 4)); + + mockMvc.perform(put("/api/immersive-votes/1/participate") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ParticipateRequest(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.action").value("VOTED")) + .andExpect(jsonPath("$.remainingFreeVotes").value(4)); + } + } + + @Nested + class getLive { + + @Test + @WithMockUser + void 실시간_결과_200_반환() throws Exception { + given(immersiveVoteQueryUseCase.getLive(1L)) + .willReturn(new ImmersiveLiveResult(1L, 60, 40, 10, 0)); + + mockMvc.perform(get("/api/immersive-votes/1/live")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.optionARatio").value(60)) + .andExpect(jsonPath("$.optionBRatio").value(40)); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java b/src/test/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java new file mode 100644 index 00000000..183540e4 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java @@ -0,0 +1,171 @@ +package com.ject.vs.vote.adapter.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ject.vs.config.OAuth2LoginSuccessHandler; +import com.ject.vs.util.CookieUtil; +import com.ject.vs.util.JwtProvider; +import com.ject.vs.vote.adapter.web.dto.ParticipateRequest; +import com.ject.vs.vote.adapter.web.dto.VoteCreateRequest; +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteEndedException; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.port.VoteDetailQueryService; +import com.ject.vs.vote.port.VoteDetailQueryService.VoteDetailResult; +import com.ject.vs.vote.port.in.VoteCommandUseCase; +import com.ject.vs.vote.port.in.VoteCommandUseCase.ParticipateResult; +import com.ject.vs.vote.port.in.VoteCommandUseCase.VoteCreateResult; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(VoteController.class) +class VoteControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @MockBean VoteCommandUseCase voteCommandUseCase; + @MockBean VoteDetailQueryService voteDetailQueryService; + @MockBean JwtProvider jwtProvider; + @MockBean CookieUtil cookieUtil; + @MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + private static final UsernamePasswordAuthenticationToken AUTH = + new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList()); + + private VoteCreateRequest validCreateRequest() { + return new VoteCreateRequest( + VoteType.GENERAL, "제목", null, "thumb.png", null, + VoteDuration.HOURS_24, "옵션A", "옵션B" + ); + } + + private VoteDetailResult sampleDetail() { + return new VoteDetailResult( + 1L, VoteType.GENERAL, "제목", null, "thumb.png", null, + VoteStatus.ONGOING, Instant.parse("2025-01-02T00:00:00Z"), + 5, List.of(), null, Map.of(), null + ); + } + + @Nested + class create { + + @Test + void 인증된_회원은_201_반환() throws Exception { + given(voteCommandUseCase.create(any())).willReturn( + new VoteCreateResult(1L, VoteStatus.ONGOING, Instant.parse("2025-01-02T00:00:00Z"))); + + mockMvc.perform(post("/api/votes") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.voteId").value(1)); + } + + @Test + void 비인증_요청은_401_반환() throws Exception { + mockMvc.perform(post("/api/votes") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + class getDetail { + + @Test + @WithMockUser + void 투표_상세_200_반환() throws Exception { + given(voteDetailQueryService.getDetail(eq(1L), any(), any())).willReturn(sampleDetail()); + + mockMvc.perform(get("/api/votes/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voteId").value(1)) + .andExpect(jsonPath("$.title").value("제목")); + } + + @Test + @WithMockUser + void 존재하지_않는_투표는_404() throws Exception { + given(voteDetailQueryService.getDetail(eq(99L), any(), any())) + .willThrow(new VoteNotFoundException()); + + mockMvc.perform(get("/api/votes/99")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("VOTE_NOT_FOUND")); + } + } + + @Nested + class participate { + + @Test + void 회원_참여_200_반환() throws Exception { + given(voteCommandUseCase.participateAsMember(eq(1L), eq(1L), eq(10L))) + .willReturn(new ParticipateResult(1L, 10L, List.of(), 1, null)); + + mockMvc.perform(post("/api/votes/1/participate") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ParticipateRequest(10L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.selectedOptionId").value(10)); + } + + @Test + void 종료된_투표_참여_403() throws Exception { + given(voteCommandUseCase.participateAsMember(any(), any(), any())) + .willThrow(new VoteEndedException()); + + mockMvc.perform(post("/api/votes/1/participate") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ParticipateRequest(10L)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("VOTE_ENDED")); + } + } + + @Nested + class cancel { + + @Test + void 회원_취소_204_반환() throws Exception { + willDoNothing().given(voteCommandUseCase).cancel(eq(1L), eq(1L)); + + mockMvc.perform(delete("/api/votes/1/participate") + .with(authentication(AUTH)).with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + void 비인증_취소_401_반환() throws Exception { + mockMvc.perform(delete("/api/votes/1/participate") + .with(csrf())) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java b/src/test/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java new file mode 100644 index 00000000..db21910e --- /dev/null +++ b/src/test/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java @@ -0,0 +1,110 @@ +package com.ject.vs.vote.adapter.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ject.vs.config.OAuth2LoginSuccessHandler; +import com.ject.vs.util.CookieUtil; +import com.ject.vs.util.JwtProvider; +import com.ject.vs.vote.adapter.web.dto.EmojiRequest; +import com.ject.vs.vote.domain.VoteEmoji; +import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase; +import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase.EmojiResult; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(VoteEmojiController.class) +class VoteEmojiControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @MockBean VoteEmojiCommandUseCase voteEmojiCommandUseCase; + @MockBean JwtProvider jwtProvider; + @MockBean CookieUtil cookieUtil; + @MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + private static final UsernamePasswordAuthenticationToken AUTH = + new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList()); + + private EmojiResult sampleResult(VoteEmoji myEmoji) { + return new EmojiResult(Map.of(VoteEmoji.LIKE, 5L, VoteEmoji.SAD, 0L, + VoteEmoji.ANGRY, 0L, VoteEmoji.WOW, 0L), 5L, myEmoji); + } + + @Nested + class reactOnVote { + + @Test + void 회원_이모지_반응_200_반환() throws Exception { + given(voteEmojiCommandUseCase.reactAsMember(eq(1L), eq(1L), eq(VoteEmoji.LIKE))) + .willReturn(sampleResult(VoteEmoji.LIKE)); + + mockMvc.perform(put("/api/votes/1/emoji") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new EmojiRequest(VoteEmoji.LIKE)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.myEmoji").value("LIKE")); + } + + @Test + @WithMockUser + void 비회원_이모지_반응_200_반환() throws Exception { + given(voteEmojiCommandUseCase.reactAsGuest(eq(1L), any(), eq(VoteEmoji.WOW))) + .willReturn(sampleResult(VoteEmoji.WOW)); + + mockMvc.perform(put("/api/votes/1/emoji") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new EmojiRequest(VoteEmoji.WOW)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.myEmoji").value("WOW")); + } + + @Test + void null_이모지로_취소_200_반환() throws Exception { + given(voteEmojiCommandUseCase.reactAsMember(eq(1L), eq(1L), isNull())) + .willReturn(sampleResult(null)); + + mockMvc.perform(put("/api/votes/1/emoji") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"emoji\":null}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.myEmoji").doesNotExist()); + } + } + + @Nested + class reactOnImmersiveVote { + + @Test + void 몰입형_이모지_반응_200_반환() throws Exception { + given(voteEmojiCommandUseCase.reactAsMember(eq(1L), eq(1L), eq(VoteEmoji.SAD))) + .willReturn(sampleResult(VoteEmoji.SAD)); + + mockMvc.perform(put("/api/immersive-votes/1/emoji") + .with(authentication(AUTH)).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new EmojiRequest(VoteEmoji.SAD)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.myEmoji").value("SAD")); + } + } +} diff --git a/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java b/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java new file mode 100644 index 00000000..5b6b4fc6 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java @@ -0,0 +1,105 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.config.OAuth2LoginSuccessHandler; +import com.ject.vs.util.CookieUtil; +import com.ject.vs.util.JwtProvider; +import com.ject.vs.vote.domain.VoteStatus; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.exception.VoteNotEndedException; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.ShareLinkResult; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.VoteResultDetail; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(VoteResultController.class) +class VoteResultControllerTest { + + @Autowired MockMvc mockMvc; + + @MockBean VoteResultQueryUseCase voteResultQueryUseCase; + @MockBean JwtProvider jwtProvider; + @MockBean CookieUtil cookieUtil; + @MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + private static final UsernamePasswordAuthenticationToken AUTH = + new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList()); + + private VoteResultDetail sampleResult() { + return new VoteResultDetail(1L, "제목", VoteStatus.ENDED, + Instant.parse("2025-01-01T01:00:00Z"), 10, List.of(), null); + } + + @Nested + class getResult { + + @Test + void 회원_결과_200_반환() throws Exception { + given(voteResultQueryUseCase.getResult(eq(1L), eq(1L))).willReturn(sampleResult()); + + mockMvc.perform(get("/api/votes/1/result").with(authentication(AUTH))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voteId").value(1)) + .andExpect(jsonPath("$.title").value("제목")); + } + + @Test + @WithMockUser + void 비회원_결과_200_반환() throws Exception { + given(voteResultQueryUseCase.getResult(eq(1L), isNull())).willReturn(sampleResult()); + + mockMvc.perform(get("/api/votes/1/result")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + void 진행중인_투표_403() throws Exception { + given(voteResultQueryUseCase.getResult(any(), any())).willThrow(new VoteNotEndedException()); + + mockMvc.perform(get("/api/votes/1/result")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("VOTE_NOT_ENDED")); + } + + @Test + @WithMockUser + void 존재하지_않는_투표_404() throws Exception { + given(voteResultQueryUseCase.getResult(any(), any())).willThrow(new VoteNotFoundException()); + + mockMvc.perform(get("/api/votes/99/result")) + .andExpect(status().isNotFound()); + } + } + + @Nested + class getShareLink { + + @Test + @WithMockUser + void 공유링크_200_반환() throws Exception { + given(voteResultQueryUseCase.getShareLink(1L)) + .willReturn(new ShareLinkResult("https://vs.app/poll/result/1")); + + mockMvc.perform(get("/api/votes/1/share")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").value("https://vs.app/poll/result/1")); + } + } +} From 537d65259c26b777fd1852d53e8999e179485ddc Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Sat, 9 May 2026 12:40:23 +0900 Subject: [PATCH 24/36] feat(vote): expand VoteResultDetail with Insight and AiInsightView --- src/main/java/com/ject/vs/domain/User.java | 7 +++- .../com/ject/vs/vote/domain/GenderCount.java | 4 +++ .../domain/VoteParticipationRepository.java | 24 ++++++++++++++ .../vote/port/in/VoteResultQueryUseCase.java | 33 ++++++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ject/vs/vote/domain/GenderCount.java diff --git a/src/main/java/com/ject/vs/domain/User.java b/src/main/java/com/ject/vs/domain/User.java index 521dce40..cc89633c 100644 --- a/src/main/java/com/ject/vs/domain/User.java +++ b/src/main/java/com/ject/vs/domain/User.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import lombok.Getter; +import java.time.LocalDate; + @Entity @Getter @Table(name = "users") @@ -11,7 +13,10 @@ public class User { private Long id; private String sub; - // 아직 유저에 대한 정보 확정 아님 + + private String gender; + + private LocalDate birthDate; public static User createWithSub(String sub) { User user = new User(); diff --git a/src/main/java/com/ject/vs/vote/domain/GenderCount.java b/src/main/java/com/ject/vs/vote/domain/GenderCount.java new file mode 100644 index 00000000..cac57310 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/GenderCount.java @@ -0,0 +1,4 @@ +package com.ject.vs.vote.domain; + +public record GenderCount(String gender, long count) { +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java index 8c266661..724c192a 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java @@ -20,6 +20,9 @@ public interface VoteParticipationRepository extends JpaRepository findAllUserIdsByVoteId(@Param("voteId") Long voteId); + @Query("SELECT p.userId FROM VoteParticipation p WHERE p.voteId = :voteId AND p.optionId = :optionId AND p.userId IS NOT NULL") + List findUserIdsByVoteIdAndOptionId(@Param("voteId") Long voteId, @Param("optionId") Long optionId); + Optional findByVoteIdAndUserId(Long voteId, Long userId); Optional findByVoteIdAndAnonymousId(Long voteId, String anonymousId); @@ -27,4 +30,25 @@ public interface VoteParticipationRepository extends JpaRepository findGenderDistribution(@Param("voteId") Long voteId, @Param("optionId") Long optionId); + + @Query(""" + SELECT new com.ject.vs.vote.domain.GenderCount(u.gender, COUNT(p)) + FROM VoteParticipation p, com.ject.vs.domain.User u + WHERE p.userId = u.id + AND p.voteId = :voteId + AND p.userId IS NOT NULL + GROUP BY u.gender + """) + List findGenderDistributionByVote(@Param("voteId") Long voteId); } diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java index 228c263c..ae384542 100644 --- a/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java +++ b/src/main/java/com/ject/vs/vote/port/in/VoteResultQueryUseCase.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port.in; +import com.ject.vs.vote.domain.InsightScope; import com.ject.vs.vote.domain.VoteStatus; import java.time.Instant; @@ -18,10 +19,40 @@ record VoteResultDetail( Instant endAt, int participantCount, List options, - Long mySelectedOptionId + Long mySelectedOptionId, + Insight insight, + AiInsightView aiInsight ) { } + record Insight( + boolean locked, + InsightScope scope, + Integer selectionCount, + GenderDistribution genderDistribution, + List ageDistribution + ) { + public static Insight ofLocked() { + return new Insight(true, null, null, null, null); + } + } + + record GenderDistribution(int maleRatio, int femaleRatio) { + } + + record AgeDistribution(String ageGroup, int ratio, boolean isMyGroup) { + } + + record AiInsightView(boolean available, String headline, String body) { + public static AiInsightView of(String headline, String body) { + return new AiInsightView(true, headline, body); + } + + public static AiInsightView unavailable() { + return new AiInsightView(false, null, null); + } + } + record ShareLinkResult(String url) { } } From f101dedb07cbc175d68f32d8745f6e8a85f3ea2c Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Sat, 9 May 2026 12:40:28 +0900 Subject: [PATCH 25/36] feat(vote): implement VoteResultQueryService with MY_SELECTION/TOTAL/locked insight --- .../vs/vote/port/VoteResultQueryService.java | 105 ++++++++++++++++-- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java index 87fbfa84..730f8c32 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java @@ -1,5 +1,7 @@ package com.ject.vs.vote.port; +import com.ject.vs.domain.User; +import com.ject.vs.repository.UserRepository; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.exception.VoteNotFoundException; import com.ject.vs.vote.exception.VoteNotEndedException; @@ -10,7 +12,11 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; +import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -20,6 +26,7 @@ public class VoteResultQueryService implements VoteResultQueryUseCase { private final VoteRepository voteRepository; private final VoteOptionRepository voteOptionRepository; private final VoteParticipationRepository voteParticipationRepository; + private final UserRepository userRepository; private final Clock clock; @Override @@ -36,16 +43,32 @@ public VoteResultDetail getResult(Long voteId, Long userId) { return new OptionResult(opt.getId(), opt.getLabel(), count, ratio); }).toList(); - Long mySelectedOptionId = null; - if (userId != null) { - mySelectedOptionId = voteParticipationRepository - .findByVoteIdAndUserId(voteId, userId) - .map(VoteParticipation::getOptionId) - .orElse(null); + if (userId == null) { + return new VoteResultDetail(voteId, vote.getTitle(), VoteStatus.ENDED, + vote.getEndAt(), (int) total, optionResults, null, + Insight.ofLocked(), AiInsightView.unavailable()); + } + + Optional myParticipation = + voteParticipationRepository.findByVoteIdAndUserId(voteId, userId); + + Long mySelectedOptionId = myParticipation.map(VoteParticipation::getOptionId).orElse(null); + + Insight insight; + AiInsightView aiInsight; + + if (myParticipation.isPresent()) { + insight = buildMySelectionInsight(voteId, mySelectedOptionId, userId); + aiInsight = vote.hasAiInsight() + ? AiInsightView.of(vote.getAiInsightHeadline(), vote.getAiInsightBody()) + : AiInsightView.unavailable(); + } else { + insight = buildTotalInsight(voteId, total, userId); + aiInsight = AiInsightView.unavailable(); } return new VoteResultDetail(voteId, vote.getTitle(), VoteStatus.ENDED, - vote.getEndAt(), (int) total, optionResults, mySelectedOptionId); + vote.getEndAt(), (int) total, optionResults, mySelectedOptionId, insight, aiInsight); } @Override @@ -53,4 +76,72 @@ public ShareLinkResult getShareLink(Long voteId) { if (!voteRepository.existsById(voteId)) throw new VoteNotFoundException(); return new ShareLinkResult("https://vs.app/poll/result/" + voteId); } + + private Insight buildMySelectionInsight(Long voteId, Long optionId, Long userId) { + int selectionCount = (int) voteParticipationRepository.countByVoteIdAndOptionId(voteId, optionId); + + List genderCounts = voteParticipationRepository.findGenderDistribution(voteId, optionId); + GenderDistribution genderDistribution = computeGenderDistribution(genderCounts); + + AgeGroup myGroup = resolveMyAgeGroup(userId); + List ageDistribution = computeAgeDistributionByOption(voteId, optionId, myGroup); + + return new Insight(false, InsightScope.MY_SELECTION, selectionCount, genderDistribution, ageDistribution); + } + + private Insight buildTotalInsight(Long voteId, long total, Long userId) { + List genderCounts = voteParticipationRepository.findGenderDistributionByVote(voteId); + GenderDistribution genderDistribution = computeGenderDistribution(genderCounts); + + AgeGroup myGroup = resolveMyAgeGroup(userId); + List ageDistribution = computeAgeDistributionByVote(voteId, myGroup); + + return new Insight(false, InsightScope.TOTAL, (int) total, genderDistribution, ageDistribution); + } + + private AgeGroup resolveMyAgeGroup(Long userId) { + return userRepository.findById(userId) + .map(u -> u.getBirthDate() != null ? AgeGroup.fromBirthDate(u.getBirthDate(), clock) : null) + .orElse(null); + } + + private GenderDistribution computeGenderDistribution(List genderCounts) { + long totalGender = genderCounts.stream().mapToLong(GenderCount::count).sum(); + if (totalGender == 0) return new GenderDistribution(0, 0); + + long maleCount = genderCounts.stream() + .filter(gc -> "MALE".equals(gc.gender())).mapToLong(GenderCount::count).sum(); + int maleRatio = (int) Math.round(maleCount * 100.0 / totalGender); + return new GenderDistribution(maleRatio, 100 - maleRatio); + } + + private List computeAgeDistributionByOption(Long voteId, Long optionId, AgeGroup myGroup) { + List userIds = voteParticipationRepository.findUserIdsByVoteIdAndOptionId(voteId, optionId); + return buildAgeDistributions(userIds, myGroup); + } + + private List computeAgeDistributionByVote(Long voteId, AgeGroup myGroup) { + List userIds = voteParticipationRepository.findAllUserIdsByVoteId(voteId); + return buildAgeDistributions(userIds, myGroup); + } + + private List buildAgeDistributions(List userIds, AgeGroup myGroup) { + List users = userRepository.findAllById(userIds); + + Map groupCounts = users.stream() + .filter(u -> u.getBirthDate() != null) + .collect(Collectors.groupingBy( + u -> AgeGroup.fromBirthDate(u.getBirthDate(), clock), + Collectors.counting())); + + long total = groupCounts.values().stream().mapToLong(Long::longValue).sum(); + + return Arrays.stream(AgeGroup.values()) + .map(group -> { + long count = groupCounts.getOrDefault(group, 0L); + int ratio = total == 0 ? 0 : (int) Math.round(count * 100.0 / total); + return new AgeDistribution(group.getLabel(), ratio, group == myGroup); + }) + .toList(); + } } From 622d5a85bc170fe88add6ebb30f1b65c9786531b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Sat, 9 May 2026 12:40:34 +0900 Subject: [PATCH 26/36] feat(vote): update VoteResultResponse to include insight and aiInsight fields --- .../adapter/web/dto/VoteResultResponse.java | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java index bcb4c7b1..e5a7cd59 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteResultResponse.java @@ -1,5 +1,9 @@ package com.ject.vs.vote.adapter.web.dto; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.AgeDistribution; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.AiInsightView; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.GenderDistribution; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.Insight; import com.ject.vs.vote.port.in.VoteResultQueryUseCase.VoteResultDetail; import java.time.Instant; @@ -14,11 +18,55 @@ public record VoteResultResponse( OffsetDateTime endAt, int participantCount, List options, - Long mySelectedOptionId + Long mySelectedOptionId, + InsightResponse insight, + AiInsightResponse aiInsight ) { public record OptionItem(Long optionId, String label, long voteCount, Integer ratio) { } + public record InsightResponse( + boolean locked, + String scope, + Integer selectionCount, + GenderDistributionResponse genderDistribution, + List ageDistribution + ) { + public record GenderDistributionResponse(int maleRatio, int femaleRatio) { + } + + public record AgeDistributionResponse(String ageGroup, int ratio, boolean isMyGroup) { + } + + static InsightResponse from(Insight insight) { + if (insight == null) return null; + if (insight.locked()) return new InsightResponse(true, null, null, null, null); + + GenderDistributionResponse gender = null; + if (insight.genderDistribution() != null) { + GenderDistribution g = insight.genderDistribution(); + gender = new GenderDistributionResponse(g.maleRatio(), g.femaleRatio()); + } + + List ages = null; + if (insight.ageDistribution() != null) { + ages = insight.ageDistribution().stream() + .map(a -> new AgeDistributionResponse(a.ageGroup(), a.ratio(), a.isMyGroup())) + .toList(); + } + + return new InsightResponse(false, insight.scope() != null ? insight.scope().name() : null, + insight.selectionCount(), gender, ages); + } + } + + public record AiInsightResponse(boolean available, String headline, String body) { + static AiInsightResponse from(AiInsightView view) { + if (view == null) return new AiInsightResponse(false, null, null); + return new AiInsightResponse(view.available(), view.headline(), view.body()); + } + } + private static OffsetDateTime toKst(Instant instant) { return instant.atOffset(ZoneOffset.ofHours(9)); } @@ -27,7 +75,16 @@ public static VoteResultResponse from(VoteResultDetail result) { List items = result.options().stream() .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) .toList(); - return new VoteResultResponse(result.voteId(), result.title(), result.status().name(), - toKst(result.endAt()), result.participantCount(), items, result.mySelectedOptionId()); + return new VoteResultResponse( + result.voteId(), + result.title(), + result.status().name(), + toKst(result.endAt()), + result.participantCount(), + items, + result.mySelectedOptionId(), + InsightResponse.from(result.insight()), + AiInsightResponse.from(result.aiInsight()) + ); } } From 811870b60af5b4404c1bc7623a2363036090ba45 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Sat, 9 May 2026 12:40:40 +0900 Subject: [PATCH 27/36] test(vote): add VoteResultQueryServiceTest and update controller test for new VoteResultDetail --- .../adapter/web/VoteResultControllerTest.java | 3 +- .../vote/port/VoteResultQueryServiceTest.java | 171 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java diff --git a/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java b/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java index 5b6b4fc6..e0b958c5 100644 --- a/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java +++ b/src/test/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java @@ -43,7 +43,8 @@ class VoteResultControllerTest { private VoteResultDetail sampleResult() { return new VoteResultDetail(1L, "제목", VoteStatus.ENDED, - Instant.parse("2025-01-01T01:00:00Z"), 10, List.of(), null); + Instant.parse("2025-01-01T01:00:00Z"), 10, List.of(), null, + VoteResultQueryUseCase.Insight.ofLocked(), VoteResultQueryUseCase.AiInsightView.unavailable()); } @Nested diff --git a/src/test/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java b/src/test/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java new file mode 100644 index 00000000..dfa4da55 --- /dev/null +++ b/src/test/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java @@ -0,0 +1,171 @@ +package com.ject.vs.vote.port; + +import com.ject.vs.repository.UserRepository; +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.exception.VoteNotFoundException; +import com.ject.vs.vote.exception.VoteNotEndedException; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.VoteResultDetail; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +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.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class VoteResultQueryServiceTest { + + @Mock VoteRepository voteRepository; + @Mock VoteOptionRepository voteOptionRepository; + @Mock VoteParticipationRepository voteParticipationRepository; + @Mock UserRepository userRepository; + + private VoteResultQueryService service; + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2025-06-01T12:00:00Z"), ZoneOffset.UTC); + + private Vote endedVote; + private Vote ongoingVote; + + @BeforeEach + void setUp() { + service = new VoteResultQueryService( + voteRepository, voteOptionRepository, voteParticipationRepository, userRepository, CLOCK); + + Clock pastClock = Clock.fixed(Instant.parse("2025-05-30T00:00:00Z"), ZoneOffset.UTC); + endedVote = Vote.create(VoteType.GENERAL, "제목", null, "thumb.png", null, + Duration.ofHours(24), pastClock); + + Clock recentClock = Clock.fixed(Instant.parse("2025-06-01T00:00:00Z"), ZoneOffset.UTC); + ongoingVote = Vote.create(VoteType.GENERAL, "진행중", null, "thumb.png", null, + Duration.ofHours(24), recentClock); + } + + @Nested + class getResult { + + @Test + void 존재하지_않는_투표_예외() { + given(voteRepository.findById(99L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getResult(99L, 1L)) + .isInstanceOf(VoteNotFoundException.class); + } + + @Test + void 진행중인_투표_예외() { + given(voteRepository.findById(1L)).willReturn(Optional.of(ongoingVote)); + + assertThatThrownBy(() -> service.getResult(1L, 1L)) + .isInstanceOf(VoteNotEndedException.class); + } + + @Test + void 비회원_locked_insight_반환() { + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(0L); + + VoteResultDetail result = service.getResult(1L, null); + + assertThat(result.insight().locked()).isTrue(); + assertThat(result.insight().scope()).isNull(); + assertThat(result.aiInsight().available()).isFalse(); + assertThat(result.mySelectedOptionId()).isNull(); + } + + @Test + void 회원_참여O_MY_SELECTION_insight() { + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(10L); + + VoteParticipation participation = VoteParticipation.ofMember(1L, 1L, 10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 1L)) + .willReturn(Optional.of(participation)); + given(voteParticipationRepository.countByVoteIdAndOptionId(1L, 10L)).willReturn(6L); + given(voteParticipationRepository.findGenderDistribution(1L, 10L)).willReturn(List.of()); + given(voteParticipationRepository.findUserIdsByVoteIdAndOptionId(1L, 10L)).willReturn(List.of()); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + VoteResultDetail result = service.getResult(1L, 1L); + + assertThat(result.insight().locked()).isFalse(); + assertThat(result.insight().scope()).isEqualTo(InsightScope.MY_SELECTION); + assertThat(result.insight().selectionCount()).isEqualTo(6); + assertThat(result.mySelectedOptionId()).isEqualTo(10L); + } + + @Test + void 회원_참여X_TOTAL_insight() { + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 1L)).willReturn(Optional.empty()); + given(voteParticipationRepository.findGenderDistributionByVote(1L)).willReturn(List.of()); + given(voteParticipationRepository.findAllUserIdsByVoteId(1L)).willReturn(List.of()); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + VoteResultDetail result = service.getResult(1L, 1L); + + assertThat(result.insight().locked()).isFalse(); + assertThat(result.insight().scope()).isEqualTo(InsightScope.TOTAL); + assertThat(result.insight().selectionCount()).isEqualTo(10); + assertThat(result.mySelectedOptionId()).isNull(); + assertThat(result.aiInsight().available()).isFalse(); + } + + @Test + void ai_insight_있으면_available_true() { + given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); + endedVote.cacheAiInsight("헤드라인", "바디"); + given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(1L)).willReturn(5L); + + VoteParticipation participation = VoteParticipation.ofMember(1L, 1L, 10L); + given(voteParticipationRepository.findByVoteIdAndUserId(1L, 1L)) + .willReturn(Optional.of(participation)); + given(voteParticipationRepository.countByVoteIdAndOptionId(1L, 10L)).willReturn(3L); + given(voteParticipationRepository.findGenderDistribution(1L, 10L)).willReturn(List.of()); + given(voteParticipationRepository.findUserIdsByVoteIdAndOptionId(1L, 10L)).willReturn(List.of()); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + VoteResultDetail result = service.getResult(1L, 1L); + + assertThat(result.aiInsight().available()).isTrue(); + assertThat(result.aiInsight().headline()).isEqualTo("헤드라인"); + assertThat(result.aiInsight().body()).isEqualTo("바디"); + } + } + + @Nested + class getShareLink { + + @Test + void 공유링크_반환() { + given(voteRepository.existsById(1L)).willReturn(true); + + assertThat(service.getShareLink(1L).url()) + .isEqualTo("https://vs.app/poll/result/1"); + } + + @Test + void 존재하지_않는_투표_예외() { + given(voteRepository.existsById(99L)).willReturn(false); + + assertThatThrownBy(() -> service.getShareLink(99L)) + .isInstanceOf(VoteNotFoundException.class); + } + } +} From f3be66ffae1e8538977369933b54466545eaaacb Mon Sep 17 00:00:00 2001 From: tlarbals824 Date: Sun, 10 May 2026 14:11:16 +0900 Subject: [PATCH 28/36] =?UTF-8?q?Extract=20=EA=B3=B5=ED=86=B5=20BusinessEx?= =?UTF-8?q?ception=20=EB=B0=8F=20ErrorCode=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공통 Exception 처리의 일관성을 위해 `BusinessException` 및 `ErrorCode` 인터페이스를 생성하고, 기존 `InvalidDurationException`과 `ImageRequiredException`을 `BusinessException`으로 변경. 각 예외에 대해 `VoteErrorCode` enum 사용으로 에러 코드와 상태 코드를 관리. GlobalExceptionHandler에 `BusinessException` 통합 처리 로직 추가. --- .../common/exception/BusinessException.java | 25 +++++++++++++++++ .../ject/vs/common/exception/ErrorCode.java | 7 +++++ .../exception/GlobalExceptionHandler.java | 12 +++------ .../exception/ImageRequiredException.java | 9 +++---- .../exception/InvalidDurationException.java | 11 +++----- .../ject/vs/vote/exception/VoteErrorCode.java | 27 +++++++++++++++++++ 6 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/ject/vs/common/exception/BusinessException.java create mode 100644 src/main/java/com/ject/vs/common/exception/ErrorCode.java create mode 100644 src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java diff --git a/src/main/java/com/ject/vs/common/exception/BusinessException.java b/src/main/java/com/ject/vs/common/exception/BusinessException.java new file mode 100644 index 00000000..9585cdd0 --- /dev/null +++ b/src/main/java/com/ject/vs/common/exception/BusinessException.java @@ -0,0 +1,25 @@ +package com.ject.vs.common.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return this.errorCode.getCode(); + } + + public String getErrorMessage() { + return this.errorCode.getMessage(); + } + + public Integer getStatusCode() { + return this.errorCode.getStatusCode(); + } +} diff --git a/src/main/java/com/ject/vs/common/exception/ErrorCode.java b/src/main/java/com/ject/vs/common/exception/ErrorCode.java new file mode 100644 index 00000000..11eac7f3 --- /dev/null +++ b/src/main/java/com/ject/vs/common/exception/ErrorCode.java @@ -0,0 +1,7 @@ +package com.ject.vs.common.exception; + +public interface ErrorCode { + String getCode(); + String getMessage(); + Integer getStatusCode(); +} diff --git a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java index b67c378e..92eefb28 100644 --- a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java @@ -40,14 +40,10 @@ public ResponseEntity handleInvalidEmoji(InvalidEmojiException e) return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); } - @ExceptionHandler(InvalidDurationException.class) - public ResponseEntity handleInvalidDuration(InvalidDurationException e) { - return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - - @ExceptionHandler(ImageRequiredException.class) - public ResponseEntity handleImageRequired(ImageRequiredException e) { - return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusiness(BusinessException e) { + return ResponseEntity.status(e.getStatusCode()) + .body(new ErrorResponse(e.getErrorCode(), e.getErrorMessage())); } @ExceptionHandler(UnauthorizedException.class) diff --git a/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java b/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java index 1435ec8f..42e74fbb 100644 --- a/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java +++ b/src/main/java/com/ject/vs/vote/exception/ImageRequiredException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class ImageRequiredException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class ImageRequiredException extends BusinessException { public ImageRequiredException() { - super("몰입형 투표에는 이미지가 필요합니다."); - } - - public String getErrorCode() { - return "IMAGE_REQUIRED"; + super(VoteErrorCode.IMAGE_REQUIRED); } } diff --git a/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java b/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java index a7fc6c2d..1a534b7f 100644 --- a/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java +++ b/src/main/java/com/ject/vs/vote/exception/InvalidDurationException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class InvalidDurationException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; - public InvalidDurationException(int hours) { - super("유효하지 않은 투표 기간입니다: " + hours + "시간"); - } - - public String getErrorCode() { - return "INVALID_DURATION"; +public class InvalidDurationException extends BusinessException { + public InvalidDurationException() { + super(VoteErrorCode.INVALID_DURATION); } } diff --git a/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java b/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java new file mode 100644 index 00000000..e98b0eb9 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java @@ -0,0 +1,27 @@ +package com.ject.vs.vote.exception; + +import com.ject.vs.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum VoteErrorCode implements ErrorCode { + IMAGE_REQUIRED( + "몰입형 투표에는 이미지가 필요합니다.", + 400 + ), + INVALID_DURATION( + "유효하지 않은 시간입니다.", + 400 + ); + + private final String message; + private final Integer statusCode; + + @Override + public String getCode() { + return this.name(); + } + +} From b356061e9961f716d6b8bc730175007f732b1fb2 Mon Sep 17 00:00:00 2001 From: tlarbals824 Date: Sun, 10 May 2026 14:13:24 +0900 Subject: [PATCH 29/36] =?UTF-8?q?VoteEmoji=EB=A5=BC=20=ED=99=9C=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20DTO=EC=9D=98=20emojiSummary=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ject/vs/vote/adapter/web/dto/EmojiResponse.java | 6 ++---- .../ject/vs/vote/adapter/web/dto/VoteDetailResponse.java | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java index 855bac39..c43495c2 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiResponse.java @@ -6,12 +6,10 @@ import java.util.Map; import java.util.stream.Collectors; -public record EmojiResponse(Map emojiSummary, long total, String myEmoji) { +public record EmojiResponse(Map emojiSummary, long total, String myEmoji) { public static EmojiResponse from(EmojiResult result) { - Map summary = result.emojiSummary().entrySet().stream() - .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue)); String myEmoji = result.myEmoji() != null ? result.myEmoji().name() : null; - return new EmojiResponse(summary, result.total(), myEmoji); + return new EmojiResponse(result.emojiSummary(), result.total(), myEmoji); } } diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java index 1f94d6aa..5137494c 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/VoteDetailResponse.java @@ -22,7 +22,7 @@ public record VoteDetailResponse( int participantCount, List options, Long mySelectedOptionId, - Map emojiSummary, + Map emojiSummary, String myEmoji ) { public record OptionItem(Long optionId, String label, long voteCount, Integer ratio) { @@ -36,14 +36,12 @@ public static VoteDetailResponse from(VoteDetailResult result) { List items = result.options().stream() .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) .toList(); - Map summary = result.emojiSummary().entrySet().stream() - .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue)); String myEmoji = result.myEmoji() != null ? result.myEmoji().name() : null; return new VoteDetailResponse( result.voteId(), result.type().name(), result.title(), result.content(), result.thumbnailUrl(), result.imageUrl(), result.status().name(), toKst(result.endAt()), result.participantCount(), items, - result.mySelectedOptionId(), summary, myEmoji + result.mySelectedOptionId(), result.emojiSummary(), myEmoji ); } } From f8269f34d084f7856c76048a8801a746c7684236 Mon Sep 17 00:00:00 2001 From: tlarbals824 Date: Sun, 10 May 2026 14:18:01 +0900 Subject: [PATCH 30/36] =?UTF-8?q?VoteEmojiReaction=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=EB=AA=A8=EC=A7=80=20=ED=86=B5=EA=B3=84=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EB=A6=AC=ED=84=B4=20=ED=83=80=EC=9E=85=EC=9D=84=20EmoijCount?= =?UTF-8?q?=20DTO=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ject/vs/vote/domain/EmoijCount.java | 5 +++++ .../vs/vote/domain/VoteEmojiReactionRepository.java | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ject/vs/vote/domain/EmoijCount.java diff --git a/src/main/java/com/ject/vs/vote/domain/EmoijCount.java b/src/main/java/com/ject/vs/vote/domain/EmoijCount.java new file mode 100644 index 00000000..242892c4 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/domain/EmoijCount.java @@ -0,0 +1,5 @@ +package com.ject.vs.vote.domain; + +public record EmoijCount(VoteEmoji emoij, long count){ + +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java index 56493fc9..71e5cea9 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteEmojiReactionRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -13,8 +14,13 @@ public interface VoteEmojiReactionRepository extends JpaRepository findByVoteIdAndAnonymousId(Long voteId, String anonymousId); - @Query("SELECT r.emoji, COUNT(r) FROM VoteEmojiReaction r WHERE r.voteId = :voteId GROUP BY r.emoji") - java.util.List countByEmojiForVote(@Param("voteId") Long voteId); + @Query(""" + SELECT new com.ject.vs.vote.domain.EmoijCount(r.emoji, COUNT(r)) + FROM VoteEmojiReaction r + WHERE r.voteId = :voteId + GROUP BY r.emoji + """) + List countByEmojiForVote(@Param("voteId") Long voteId); long countByVoteId(Long voteId); } From 2433c9deab6493c7982069a4e648ce042d4ce7e0 Mon Sep 17 00:00:00 2001 From: tlarbals824 Date: Sun, 10 May 2026 14:24:37 +0900 Subject: [PATCH 31/36] =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20WebSocket=20=EC=97=B0=EB=8F=99=20=EA=B4=80=EB=A0=A8=20Redis?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=EA=B3=84=ED=9A=8D=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ject/vs/vote/port/ImmersiveVoteQueryService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java index ca340c2a..bfa1a089 100644 --- a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java @@ -54,6 +54,16 @@ public ImmersiveLiveResult getLive(Long voteId) { } // TODO: currentViewerCount — Redis 도입 후 갱신 예정 + /** + * 클라이언트 <-> 서버 웹소캣으로 연결을 해 + * + * 클라이언트가 -> 서버 + * 1. 해당 투표 화면을 보고 있다.(조회 api) + * 2. 서버에서는 조회가 들어왔네? 그러면 redis set 자료구조로 user id를 넣어, key vote:{voteId} 구성 + * 3. redis에 넣은 결과 얼마나 있는지? 웹소캣으로 뿌려줘 + * 4. userId별로 ttl을 넣어야함. 이를 위해서 redis를 넣을 수도 있어보이고, 아니면 데이터베이스로 해결할 수 있는 부분이 있다면 그것도 좋아보임 + * 5. 이 사람이 언제 조회했는지? 유효시간을 1분으로줘, 프론트에 1분마다 요청해주세요 + */ return new ImmersiveLiveResult(voteId, aRatio, bRatio, (int) total, 0); } From 2ecd352d70fc6e9019f0528e3e7564048fbac0b1 Mon Sep 17 00:00:00 2001 From: tlarbals824 Date: Sun, 10 May 2026 14:35:58 +0900 Subject: [PATCH 32/36] =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=83=81=ED=83=9C(s?= =?UTF-8?q?tatus)=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20VoteEndedEvent=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/handler/VoteEventHandler.java | 36 +++++++++++++++++++ .../java/com/ject/vs/vote/domain/Vote.java | 22 ++++++++---- .../ject/vs/vote/domain/VoteRepository.java | 2 +- .../vs/vote/scheduler/VoteCloseScheduler.java | 1 - 4 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ject/vs/vote/adapter/handler/VoteEventHandler.java diff --git a/src/main/java/com/ject/vs/vote/adapter/handler/VoteEventHandler.java b/src/main/java/com/ject/vs/vote/adapter/handler/VoteEventHandler.java new file mode 100644 index 00000000..8189c6b5 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/handler/VoteEventHandler.java @@ -0,0 +1,36 @@ +package com.ject.vs.vote.adapter.handler; + +import com.ject.vs.vote.event.VoteEndedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class VoteEventHandler { + + /** + * VoteCloseScheduler 요기서 해당 이벤트가 발행됨\ + * + * EventListener는 동기로 동작해 그래서 처리량을 위해 async를 사용합니다. + * + * 다만 다음과 같은 고민할 부분이 존재합니다. + * + * 1. 비동기로 처리할 때, 유실되면 어떻게 처리할거에요? + * 2. 스케줄러에 의해 트리거 되는데, 1분 주기보다 처리 시간이 길어지면 같은 처리가 동시에 진행될 수 있어 보여요 어떻게 처리할거에요? + * 3. 멱등하게 어떻게 처리할 수 있을까요? + * + * + * 트랜잭션 아웃박스 패턴을 보면 좋을듯! + * 분산락을 사용할 수 있어보임 + * + * 아웃박스를 구현할 떄, 발행 기준으로 저장할래? 아니면 컨슈밍 기준으로 만들래? + */ + @EventListener(classes = VoteEndedEvent.class) + @Async("voteCloseExecutor") + public void handleVoteEnded(VoteEndedEvent event){ + // log 추가하면 좋아보임 + // ai 분석을 시작하면 좋아보임 + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/Vote.java b/src/main/java/com/ject/vs/vote/domain/Vote.java index 3c03ad05..8ea4977c 100644 --- a/src/main/java/com/ject/vs/vote/domain/Vote.java +++ b/src/main/java/com/ject/vs/vote/domain/Vote.java @@ -32,9 +32,9 @@ public class Vote extends BaseTimeEntity { private String imageUrl; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private VoteStatus status; +// @Enumerated(EnumType.STRING) +// @Column(nullable = false) +// private VoteStatus status; @Column(nullable = false) private Instant endAt; @@ -54,7 +54,7 @@ public static Vote create(VoteType type, String title, String content, vote.content = content; vote.thumbnailUrl = thumbnailUrl; vote.imageUrl = imageUrl; - vote.status = VoteStatus.ONGOING; +// vote.status = VoteStatus.ONGOING; vote.endAt = Instant.now(clock).plus(validityPeriod); return vote; } @@ -68,11 +68,19 @@ public boolean isEnded(Clock clock) { return !isOngoing(clock); } - /** 스케줄러용 — status 컬럼을 ENDED로 마킹 (캐시 갱신) */ - public void markEnded() { - this.status = VoteStatus.ENDED; + public VoteStatus getStatus(Clock clock) { + return isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; } + public VoteStatus getStatus(){ + return getStatus(Clock.systemUTC()); + } + +// /** 스케줄러용 — status 컬럼을 ENDED로 마킹 (캐시 갱신) */ +// public void markEnded() { +// this.status = VoteStatus.ENDED; +// } + public void cacheAiInsight(String headline, String body) { this.aiInsightHeadline = headline; this.aiInsightBody = body; diff --git a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java index 6ab762b6..4c2e8a69 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java @@ -11,7 +11,7 @@ public interface VoteRepository extends JpaRepository { - @Query("SELECT v FROM Vote v WHERE v.status = com.ject.vs.vote.domain.VoteStatus.ONGOING AND v.endAt < :now") + @Query("SELECT v FROM Vote v WHERE v.endAt < :now") List findExpiredOngoing(@Param("now") Instant now); List findAllByIdIn(List ids); diff --git a/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java b/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java index d015318c..d860d010 100644 --- a/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java +++ b/src/main/java/com/ject/vs/vote/scheduler/VoteCloseScheduler.java @@ -31,7 +31,6 @@ public class VoteCloseScheduler { public void closeExpiredVotes() { List expired = voteRepository.findExpiredOngoing(Instant.now(clock)); for (Vote vote : expired) { - vote.markEnded(); eventPublisher.publishEvent(new VoteEndedEvent(vote.getId())); } if (!expired.isEmpty()) { From 85d93959a6f11440a5dda7048ffefda3d8a87370 Mon Sep 17 00:00:00 2001 From: tlarbals824 Date: Sun, 10 May 2026 15:00:49 +0900 Subject: [PATCH 33/36] =?UTF-8?q?VoteEmojiReaction=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20Nullable=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vs/vote/adapter/web/dto/EmojiRequest.java | 3 +- .../com/ject/vs/vote/domain/VoteEmoji.java | 11 +++++- .../vs/vote/port/VoteEmojiCommandService.java | 39 +++++++------------ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java index 0a36e114..7096b067 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/EmojiRequest.java @@ -1,7 +1,8 @@ package com.ject.vs.vote.adapter.web.dto; import com.ject.vs.vote.domain.VoteEmoji; +import org.jspecify.annotations.Nullable; -public record EmojiRequest(VoteEmoji emoji) { +public record EmojiRequest(@Nullable VoteEmoji emoji) { // emoji == null 이면 취소 } diff --git a/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java b/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java index a9c7205f..b35f859d 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteEmoji.java @@ -1,5 +1,14 @@ package com.ject.vs.vote.domain; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + public enum VoteEmoji { - LIKE, SAD, ANGRY, WOW + LIKE, SAD, ANGRY, WOW; + + public static Map getMap() { + return Arrays.stream(VoteEmoji.values()) + .collect(Collectors.toMap(e -> e, e -> 0L)); + } } diff --git a/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java index 180fd7cd..d018989c 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java @@ -21,17 +21,18 @@ public class VoteEmojiCommandService implements VoteEmojiCommandUseCase { @Override public EmojiResult reactAsMember(Long voteId, Long userId, VoteEmoji emoji) { Optional existing = reactionRepository.findByVoteIdAndUserId(voteId, userId); - VoteEmoji resultEmoji = applyReaction(existing, emoji, - () -> reactionRepository.save(VoteEmojiReaction.ofMember(voteId, userId, emoji))); - return buildResult(voteId, resultEmoji); + VoteEmojiReaction resultEmoji = applyReaction(existing, emoji != null ? VoteEmojiReaction.ofMember(voteId, userId, emoji) : null); + return buildResult(voteId, resultEmoji.getEmoji()); } @Override public EmojiResult reactAsGuest(Long voteId, String anonymousId, VoteEmoji emoji) { Optional existing = reactionRepository.findByVoteIdAndAnonymousId(voteId, anonymousId); - VoteEmoji resultEmoji = applyReaction(existing, emoji, - () -> reactionRepository.save(VoteEmojiReaction.ofGuest(voteId, anonymousId, emoji))); - return buildResult(voteId, resultEmoji); + VoteEmojiReaction resultEmoji = applyReaction( + existing, + emoji != null ? VoteEmojiReaction.ofGuest(voteId, anonymousId, emoji) : null + ); + return buildResult(voteId, resultEmoji.getEmoji()); } /** @@ -41,29 +42,19 @@ public EmojiResult reactAsGuest(Long voteId, String anonymousId, VoteEmoji emoji * 기존 없음 + emoji 있음 → 신규 (create via newReactionSaver) * Returns the current emoji after the operation (null if canceled/deleted). */ - private VoteEmoji applyReaction(Optional existing, - VoteEmoji emoji, - Runnable newReactionSaver) { - if (existing.isPresent()) { - VoteEmojiReaction reaction = existing.get(); - if (emoji == null || reaction.getEmoji() == emoji) { - reactionRepository.delete(reaction); - return null; - } - reaction.changeEmoji(emoji); - return emoji; - } - if (emoji == null) return null; - newReactionSaver.run(); - return emoji; + private VoteEmojiReaction applyReaction(Optional existing, + VoteEmojiReaction emojiReaction) { + existing.ifPresent(reactionRepository::delete); + if (emojiReaction == null) return null; + reactionRepository.save(emojiReaction); + return emojiReaction; } private EmojiResult buildResult(Long voteId, VoteEmoji myEmoji) { - Map summary = Arrays.stream(VoteEmoji.values()) - .collect(Collectors.toMap(e -> e, e -> 0L)); + Map summary = VoteEmoji.getMap(); reactionRepository.countByEmojiForVote(voteId) - .forEach(row -> summary.put((VoteEmoji) row[0], (Long) row[1])); + .forEach(row -> summary.put(row.emoij(), row.count())); long total = summary.values().stream().mapToLong(Long::longValue).sum(); return new EmojiResult(summary, total, myEmoji); From c10b9bb7fe39150afc0ed64bb9df3bd42abb4e0f Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 12 May 2026 11:50:42 +0900 Subject: [PATCH 34/36] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20BusinessExce?= =?UTF-8?q?ption=20+=20VoteErrorCode=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 vote 예외 클래스들(VoteNotFoundException 등 7개)을 BusinessException 상속으로 변환 - VoteErrorCode에 누락된 에러 코드(VOTE_NOT_FOUND, VOTE_ENDED 등) 추가 - GlobalExceptionHandler에서 개별 vote 예외 핸들러 제거, BusinessException 단일 핸들러로 통합 --- .../exception/GlobalExceptionHandler.java | 36 ------------------- .../vote/exception/InvalidEmojiException.java | 9 ++--- .../exception/InvalidOptionException.java | 9 ++--- .../vote/exception/UnauthorizedException.java | 11 ++---- .../vs/vote/exception/VoteEndedException.java | 9 ++--- .../ject/vs/vote/exception/VoteErrorCode.java | 17 ++++----- .../VoteFreeLimitExceededException.java | 9 ++--- .../vote/exception/VoteNotEndedException.java | 9 ++--- .../vote/exception/VoteNotFoundException.java | 9 ++--- 9 files changed, 30 insertions(+), 88 deletions(-) diff --git a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java index 92eefb28..815c6de7 100644 --- a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,5 @@ package com.ject.vs.common.exception; -import com.ject.vs.vote.exception.*; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -10,47 +9,12 @@ @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(VoteNotFoundException.class) - public ResponseEntity handleVoteNotFound(VoteNotFoundException e) { - return ResponseEntity.status(404).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - - @ExceptionHandler(VoteEndedException.class) - public ResponseEntity handleVoteEnded(VoteEndedException e) { - return ResponseEntity.status(403).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - - @ExceptionHandler(VoteNotEndedException.class) - public ResponseEntity handleVoteNotEnded(VoteNotEndedException e) { - return ResponseEntity.status(403).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - - @ExceptionHandler(VoteFreeLimitExceededException.class) - public ResponseEntity handleVoteFreeLimit(VoteFreeLimitExceededException e) { - return ResponseEntity.status(403).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - - @ExceptionHandler(InvalidOptionException.class) - public ResponseEntity handleInvalidOption(InvalidOptionException e) { - return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - - @ExceptionHandler(InvalidEmojiException.class) - public ResponseEntity handleInvalidEmoji(InvalidEmojiException e) { - return ResponseEntity.status(400).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - @ExceptionHandler(BusinessException.class) public ResponseEntity handleBusiness(BusinessException e) { return ResponseEntity.status(e.getStatusCode()) .body(new ErrorResponse(e.getErrorCode(), e.getErrorMessage())); } - @ExceptionHandler(UnauthorizedException.class) - public ResponseEntity handleUnauthorized(UnauthorizedException e) { - return ResponseEntity.status(401).body(new ErrorResponse(e.getErrorCode(), e.getMessage())); - } - @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldErrors().stream() diff --git a/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java b/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java index 235b7361..9c0249d7 100644 --- a/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java +++ b/src/main/java/com/ject/vs/vote/exception/InvalidEmojiException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class InvalidEmojiException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class InvalidEmojiException extends BusinessException { public InvalidEmojiException() { - super("유효하지 않은 이모지입니다."); - } - - public String getErrorCode() { - return "INVALID_EMOJI"; + super(VoteErrorCode.INVALID_EMOJI); } } diff --git a/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java b/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java index 3b28be80..dfdd6332 100644 --- a/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java +++ b/src/main/java/com/ject/vs/vote/exception/InvalidOptionException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class InvalidOptionException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class InvalidOptionException extends BusinessException { public InvalidOptionException() { - super("유효하지 않은 투표 선택지입니다."); - } - - public String getErrorCode() { - return "INVALID_OPTION"; + super(VoteErrorCode.INVALID_OPTION); } } diff --git a/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java b/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java index 7e31887a..d3cea945 100644 --- a/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java +++ b/src/main/java/com/ject/vs/vote/exception/UnauthorizedException.java @@ -1,14 +1,9 @@ package com.ject.vs.vote.exception; -public class UnauthorizedException extends RuntimeException { - - private static final String ERROR_CODE = "UNAUTHORIZED"; +import com.ject.vs.common.exception.BusinessException; +public class UnauthorizedException extends BusinessException { public UnauthorizedException() { - super("인증이 필요합니다"); - } - - public String getErrorCode() { - return ERROR_CODE; + super(VoteErrorCode.UNAUTHORIZED); } } diff --git a/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java b/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java index 5b95ac17..83b55ce0 100644 --- a/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java +++ b/src/main/java/com/ject/vs/vote/exception/VoteEndedException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class VoteEndedException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class VoteEndedException extends BusinessException { public VoteEndedException() { - super("이미 종료된 투표입니다."); - } - - public String getErrorCode() { - return "VOTE_ENDED"; + super(VoteErrorCode.VOTE_ENDED); } } diff --git a/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java b/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java index e98b0eb9..5df77732 100644 --- a/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java +++ b/src/main/java/com/ject/vs/vote/exception/VoteErrorCode.java @@ -7,14 +7,15 @@ @Getter @RequiredArgsConstructor public enum VoteErrorCode implements ErrorCode { - IMAGE_REQUIRED( - "몰입형 투표에는 이미지가 필요합니다.", - 400 - ), - INVALID_DURATION( - "유효하지 않은 시간입니다.", - 400 - ); + VOTE_NOT_FOUND("투표를 찾을 수 없습니다.", 404), + VOTE_ENDED("이미 종료된 투표입니다.", 403), + VOTE_NOT_ENDED("아직 진행 중인 투표입니다.", 403), + VOTE_FREE_LIMIT_EXCEEDED("무료 투표 횟수를 초과했습니다.", 403), + INVALID_OPTION("유효하지 않은 투표 선택지입니다.", 400), + INVALID_EMOJI("유효하지 않은 이모지입니다.", 400), + UNAUTHORIZED("인증이 필요합니다.", 401), + IMAGE_REQUIRED("몰입형 투표에는 이미지가 필요합니다.", 400), + INVALID_DURATION("유효하지 않은 시간입니다.", 400); private final String message; private final Integer statusCode; diff --git a/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java b/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java index 963aae67..a9cc9c5c 100644 --- a/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java +++ b/src/main/java/com/ject/vs/vote/exception/VoteFreeLimitExceededException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class VoteFreeLimitExceededException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class VoteFreeLimitExceededException extends BusinessException { public VoteFreeLimitExceededException() { - super("무료 투표 횟수를 초과했습니다."); - } - - public String getErrorCode() { - return "VOTE_FREE_LIMIT_EXCEEDED"; + super(VoteErrorCode.VOTE_FREE_LIMIT_EXCEEDED); } } diff --git a/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java b/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java index 6289105b..9a5cbe6f 100644 --- a/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java +++ b/src/main/java/com/ject/vs/vote/exception/VoteNotEndedException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class VoteNotEndedException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class VoteNotEndedException extends BusinessException { public VoteNotEndedException() { - super("아직 진행 중인 투표입니다."); - } - - public String getErrorCode() { - return "VOTE_NOT_ENDED"; + super(VoteErrorCode.VOTE_NOT_ENDED); } } diff --git a/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java b/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java index aa304b66..1f69ec2b 100644 --- a/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java +++ b/src/main/java/com/ject/vs/vote/exception/VoteNotFoundException.java @@ -1,12 +1,9 @@ package com.ject.vs.vote.exception; -public class VoteNotFoundException extends RuntimeException { +import com.ject.vs.common.exception.BusinessException; +public class VoteNotFoundException extends BusinessException { public VoteNotFoundException() { - super("투표를 찾을 수 없습니다."); - } - - public String getErrorCode() { - return "VOTE_NOT_FOUND"; + super(VoteErrorCode.VOTE_NOT_FOUND); } } From b43f87105785b83d9379813f366063fa8764cec0 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 12 May 2026 13:10:08 +0900 Subject: [PATCH 35/36] =?UTF-8?q?git=20commit=20-m=20"refactor:=20Object[]?= =?UTF-8?q?=20=EC=BA=90=EC=8A=A4=ED=8C=85=EC=9D=84=20EmoijCount=20Record?= =?UTF-8?q?=20Projection=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java index 9dea41ec..1f73aaff 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java @@ -54,7 +54,7 @@ public VoteDetailResult getDetail(Long voteId, Long userId, String anonymousId) Map emojiSummary = Arrays.stream(VoteEmoji.values()) .collect(Collectors.toMap(e -> e, e -> 0L)); emojiReactionRepository.countByEmojiForVote(voteId) - .forEach(row -> emojiSummary.put((VoteEmoji) row[0], (Long) row[1])); + .forEach(row -> emojiSummary.put(row.emoij(), row.count())); VoteEmoji myEmoji = null; if (userId != null) { From 4940ee2de311cd507ee4f8f982ef7051bed7083b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 12 May 2026 13:13:10 +0900 Subject: [PATCH 36/36] =?UTF-8?q?git=20commit=20-m=20"refactor:=20status?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=A0=9C=EA=B1=B0=20=ED=9B=84=20endAt?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EB=8F=99=EC=A0=81=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서비스 레이어에서 직접 계산하던 VoteStatus를 vote.getStatus(clock) 위임으로 변경" --- .../java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java | 2 +- .../java/com/ject/vs/vote/port/VoteDetailQueryService.java | 2 +- src/main/java/com/ject/vs/vote/port/VoteQueryService.java | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java index bfa1a089..041a3952 100644 --- a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java @@ -68,7 +68,7 @@ public ImmersiveLiveResult getLive(Long voteId) { } private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) { - VoteStatus status = vote.isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; + VoteStatus status = vote.getStatus(clock); int participantCount = (int) voteParticipationRepository.countByVoteId(vote.getId()); Long mySelectedOptionId = null; diff --git a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java index 1f73aaff..72469c77 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java @@ -27,7 +27,7 @@ public class VoteDetailQueryService { public VoteDetailResult getDetail(Long voteId, Long userId, String anonymousId) { Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); - VoteStatus status = vote.isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; + VoteStatus status = vote.getStatus(clock); List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); long total = voteParticipationRepository.countByVoteId(voteId); diff --git a/src/main/java/com/ject/vs/vote/port/VoteQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteQueryService.java index 4fa8b088..a1a59e7f 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteQueryService.java @@ -36,8 +36,7 @@ public Optional getSelectedOptionId(Long voteId, Long userId) { @Override public VoteSummary getVoteSummary(Long voteId) { Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); - VoteStatus computedStatus = vote.isOngoing(clock) ? VoteStatus.ONGOING : VoteStatus.ENDED; - return new VoteSummary(vote.getId(), vote.getTitle(), computedStatus, vote.getEndAt()); + return new VoteSummary(vote.getId(), vote.getTitle(), vote.getStatus(clock), vote.getEndAt()); } @Override