From 03fe4738af50b7f0c31167d8460797283cc1aea3 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:01:02 +0900 Subject: [PATCH 01/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post, post_entity_tag, post_recommend 테이블을 신설한다. entity_type은 Inquiry.targetId 패턴을 따라 FK 없이 enum+id 조합으로 폴리모픽 참조를 표현하며, content는 Hibernate 6.6 네이티브 JSON 매핑으로 jsonb 컬럼에 저장한다. - V30 마이그레이션: post/post_entity_tag/post_recommend 테이블 및 인덱스 - Post/PostEntityTag/PostRecommend 엔티티, PostCategory/EntityType enum - ErrorCode: POST_NOT_FOUND, ALREADY_RECOMMENDED, NOT_RECOMMENDED 추가 이슈 #116 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/exception/ErrorCode.java | 5 ++ .../Backend/post/entity/EntityType.java | 5 ++ .../com/Coming/Backend/post/entity/Post.java | 54 +++++++++++++++++++ .../Backend/post/entity/PostCategory.java | 5 ++ .../Backend/post/entity/PostEntityTag.java | 39 ++++++++++++++ .../Backend/post/entity/PostRecommend.java | 33 ++++++++++++ .../AlreadyRecommendedException.java | 11 ++++ .../exception/NotRecommendedException.java | 11 ++++ .../post/exception/PostNotFoundException.java | 11 ++++ .../repository/PostEntityTagRepository.java | 7 +++ .../repository/PostRecommendRepository.java | 7 +++ .../post/repository/PostRepository.java | 7 +++ .../db/migration/V30__create_post_tables.sql | 34 ++++++++++++ 13 files changed, 229 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/entity/EntityType.java create mode 100644 src/main/java/com/Coming/Backend/post/entity/Post.java create mode 100644 src/main/java/com/Coming/Backend/post/entity/PostCategory.java create mode 100644 src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java create mode 100644 src/main/java/com/Coming/Backend/post/entity/PostRecommend.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java create mode 100644 src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java create mode 100644 src/main/java/com/Coming/Backend/post/repository/PostRepository.java create mode 100644 src/main/resources/db/migration/V30__create_post_tables.sql diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index dfd5f65..599619c 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -49,6 +49,11 @@ public enum ErrorCode { INVALID_INQUIRY_STATUS(HttpStatus.BAD_REQUEST, "변경 불가능한 문의 상태입니다."), TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 대상입니다."), + // Post + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + ALREADY_RECOMMENDED(HttpStatus.CONFLICT, "이미 추천한 게시글입니다."), + NOT_RECOMMENDED(HttpStatus.BAD_REQUEST, "추천하지 않은 게시글입니다."), + // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), diff --git a/src/main/java/com/Coming/Backend/post/entity/EntityType.java b/src/main/java/com/Coming/Backend/post/entity/EntityType.java new file mode 100644 index 0000000..e52d946 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/EntityType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.post.entity; + +public enum EntityType { + CONCERT, ARTIST, RELEASE +} diff --git a/src/main/java/com/Coming/Backend/post/entity/Post.java b/src/main/java/com/Coming/Backend/post/entity/Post.java new file mode 100644 index 0000000..3a6528d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/Post.java @@ -0,0 +1,54 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private PostCategory category; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "content", nullable = false, columnDefinition = "jsonb") + private String content; + + @Column(name = "content_text", nullable = false, columnDefinition = "text") + private String contentText; + + @Column(name = "recommend_count", nullable = false) + private Long recommendCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; +} diff --git a/src/main/java/com/Coming/Backend/post/entity/PostCategory.java b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java new file mode 100644 index 0000000..704c495 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.post.entity; + +public enum PostCategory { + REVIEW, INFO, FREE +} diff --git a/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java b/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java new file mode 100644 index 0000000..f53d02d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java @@ -0,0 +1,39 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_entity_tag") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PostEntityTag extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Enumerated(EnumType.STRING) + @Column(name = "entity_type", nullable = false, length = 20) + private EntityType entityType; + + @Column(name = "entity_id", nullable = false) + private Long entityId; +} diff --git a/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java b/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java new file mode 100644 index 0000000..7751b94 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java @@ -0,0 +1,33 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_recommend") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PostRecommend extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "post_id", nullable = false) + private Long postId; +} diff --git a/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java new file mode 100644 index 0000000..c453a63 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class AlreadyRecommendedException extends BusinessException { + + public AlreadyRecommendedException() { + super(ErrorCode.ALREADY_RECOMMENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java new file mode 100644 index 0000000..0caf1b7 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class NotRecommendedException extends BusinessException { + + public NotRecommendedException() { + super(ErrorCode.NOT_RECOMMENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java new file mode 100644 index 0000000..1d11181 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PostNotFoundException extends BusinessException { + + public PostNotFoundException() { + super(ErrorCode.POST_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java new file mode 100644 index 0000000..41570e2 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.PostEntityTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostEntityTagRepository extends JpaRepository { +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java new file mode 100644 index 0000000..72c984c --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.PostRecommend; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRecommendRepository extends JpaRepository { +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java new file mode 100644 index 0000000..eea61ec --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V30__create_post_tables.sql b/src/main/resources/db/migration/V30__create_post_tables.sql new file mode 100644 index 0000000..84faf5c --- /dev/null +++ b/src/main/resources/db/migration/V30__create_post_tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE post ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + category varchar(20) NOT NULL, + title varchar(255) NOT NULL, + content jsonb NOT NULL, + content_text text NOT NULL, + recommend_count bigint NOT NULL DEFAULT 0, + view_count bigint NOT NULL DEFAULT 0, + created_at timestamp, + updated_at timestamp +); + +CREATE INDEX idx_post_user_id ON post (user_id); +CREATE INDEX idx_post_category ON post (category); + +CREATE TABLE post_entity_tag ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL, + entity_type varchar(20) NOT NULL, + entity_id bigint NOT NULL, + created_at timestamp +); + +CREATE INDEX idx_post_entity_tag_post_id ON post_entity_tag (post_id); +CREATE INDEX idx_post_entity_tag_entity_type_entity_id ON post_entity_tag (entity_type, entity_id); + +CREATE TABLE post_recommend ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + post_id bigint NOT NULL, + created_at timestamp, + UNIQUE (user_id, post_id) +); From 91051d7133d06a9a785c0df17328679251a77759 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:03:52 +0900 Subject: [PATCH 02/54] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게시글 entityTags(공연/아티스트/음악 멘션)를 화면에 표시하려면 FE가 엔티티 ID만으로는 제목·부제·썸네일을 알 수 없다. BE가 응답 시점에 Concert/Artist/ReleaseGroup을 조인해 EntityCardResponse로 채워주는 EntityLookupService를 도입한다. 상세·목록·백링크·통합검색 응답에서 공통으로 재사용한다. - EntityCardResponse(type, id, title, subtitle, thumbnailUrl) DTO - EntityLookupService.findCards(): (type, id) 키 목록을 배치 조회, 삭제된 참조는 결과에서 조용히 제외 - EntityLookupServiceTest: 타입별 매핑·혼합 조회·삭제된 참조 제외 검증 이슈 #116 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/dto/EntityCardResponse.java | 12 ++ .../post/service/EntityLookupService.java | 77 ++++++++ .../post/service/EntityLookupServiceTest.java | 180 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/service/EntityLookupService.java create mode 100644 src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java new file mode 100644 index 0000000..11e5a0b --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java @@ -0,0 +1,12 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; + +public record EntityCardResponse( + EntityType type, + Long id, + String title, + String subtitle, + String thumbnailUrl +) { +} diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java new file mode 100644 index 0000000..483198a --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -0,0 +1,77 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EntityLookupService { + + private final ConcertRepository concertRepository; + private final ArtistRepository artistRepository; + private final ReleaseGroupRepository releaseGroupRepository; + + /** + * entityType·entityId 키 목록에 대응하는 엔티티 카드 정보를 일괄 조회한다. + * 참조 대상이 삭제된 경우 결과 Map에서 해당 키가 제외된다. + */ + public Map findCards(Collection keys) { + Map result = new HashMap<>(); + + concertRepository.findAllById(idsOf(keys, EntityType.CONCERT)).forEach(concert -> + result.put(new EntityKey(EntityType.CONCERT, concert.getId()), toCard(concert))); + + artistRepository.findAllById(idsOf(keys, EntityType.ARTIST)).forEach(artist -> + result.put(new EntityKey(EntityType.ARTIST, artist.getId()), toCard(artist))); + + List releases = releaseGroupRepository.findAllById(idsOf(keys, EntityType.RELEASE)); + Map artistNames = artistRepository.findAllById( + releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + releases.forEach(release -> + result.put(new EntityKey(EntityType.RELEASE, release.getId()), + toCard(release, artistNames.get(release.getArtistId())))); + + return result; + } + + private Set idsOf(Collection keys, EntityType type) { + return keys.stream() + .filter(key -> key.type() == type) + .map(EntityKey::id) + .collect(Collectors.toSet()); + } + + private EntityCardResponse toCard(Concert concert) { + String subtitle = concert.getStartDate() + " · " + concert.getVenueName(); + return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl()); + } + + private EntityCardResponse toCard(Artist artist) { + return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl()); + } + + private EntityCardResponse toCard(ReleaseGroup release, String artistName) { + return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl()); + } + + public record EntityKey(EntityType type, Long id) { + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java new file mode 100644 index 0000000..b5f5fa4 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java @@ -0,0 +1,180 @@ +package com.Coming.Backend.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.EntityLookupService.EntityKey; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; + +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; + +@ExtendWith(MockitoExtension.class) +class EntityLookupServiceTest { + + @InjectMocks + private EntityLookupService entityLookupService; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ArtistRepository artistRepository; + + @Mock + private ReleaseGroupRepository releaseGroupRepository; + + private static final Long CONCERT_ID = 1L; + private static final Long ARTIST_ID = 10L; + private static final Long RELEASE_ID = 100L; + private static final Long RELEASE_ARTIST_ID = 20L; + + private Concert buildConcert(Long id) { + return Concert.builder() + .id(id) + .title("IU Concert : HEREH WORLD TOUR") + .startDate(LocalDate.of(2025, 10, 1)) + .venueName("올림픽공원 케이스포돔") + .posterUrl("https://example.com/concert-poster.jpg") + .build(); + } + + private Artist buildArtist(Long id, String name) { + return Artist.builder() + .id(id) + .mbid("mbid-" + id) + .name(name) + .imageUrl("https://example.com/artist-" + id + ".jpg") + .build(); + } + + private ReleaseGroup buildReleaseGroup(Long id, Long artistId) { + return ReleaseGroup.builder() + .id(id) + .artistId(artistId) + .title("LILAC") + .coverUrl("https://example.com/release-" + id + ".jpg") + .build(); + } + + @Test + void should_fill_subtitle_with_date_and_venue_and_thumbnail_with_poster_url_when_concert_key_given() { + // given + Concert concert = buildConcert(CONCERT_ID); + given(concertRepository.findAllById(any())).willReturn(List.of(concert)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.CONCERT, CONCERT_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.CONCERT, CONCERT_ID)); + assertThat(card.subtitle()).isEqualTo("2025-10-01 · 올림픽공원 케이스포돔"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/concert-poster.jpg"); + } + + @Test + void should_return_null_subtitle_and_image_url_as_thumbnail_when_artist_key_given() { + // given + Artist artist = buildArtist(ARTIST_ID, "IU"); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.ARTIST, ARTIST_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.ARTIST, ARTIST_ID)); + assertThat(card.subtitle()).isNull(); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/artist-10.jpg"); + } + + @Test + void should_fill_subtitle_with_joined_artist_name_and_thumbnail_with_cover_url_when_release_key_given() { + // given + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist artist = buildArtist(RELEASE_ARTIST_ID, "IU"); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.RELEASE, RELEASE_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.RELEASE, RELEASE_ID)); + assertThat(card.subtitle()).isEqualTo("IU"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); + } + + @Test + void should_fill_all_three_types_when_mixed_keys_given() { + // given + Concert concert = buildConcert(CONCERT_ID); + Artist directArtist = buildArtist(ARTIST_ID, "IU"); + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist releaseArtist = buildArtist(RELEASE_ARTIST_ID, "BTS"); + + given(concertRepository.findAllById(Set.of(CONCERT_ID))).willReturn(List.of(concert)); + given(artistRepository.findAllById(Set.of(ARTIST_ID))).willReturn(List.of(directArtist)); + given(releaseGroupRepository.findAllById(Set.of(RELEASE_ID))).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(Set.of(RELEASE_ARTIST_ID))).willReturn(List.of(releaseArtist)); + + List keys = List.of( + new EntityKey(EntityType.CONCERT, CONCERT_ID), + new EntityKey(EntityType.ARTIST, ARTIST_ID), + new EntityKey(EntityType.RELEASE, RELEASE_ID) + ); + + // when + Map result = entityLookupService.findCards(keys); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(new EntityKey(EntityType.CONCERT, CONCERT_ID)).type()).isEqualTo(EntityType.CONCERT); + assertThat(result.get(new EntityKey(EntityType.ARTIST, ARTIST_ID)).type()).isEqualTo(EntityType.ARTIST); + assertThat(result.get(new EntityKey(EntityType.RELEASE, RELEASE_ID)).type()).isEqualTo(EntityType.RELEASE); + assertThat(result.get(new EntityKey(EntityType.RELEASE, RELEASE_ID)).subtitle()).isEqualTo("BTS"); + } + + @Test + void should_exclude_key_from_result_when_entity_does_not_exist() { + // given + given(concertRepository.findAllById(any())).willReturn(List.of()); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.CONCERT, 999L))); + + // then + assertThat(result).isEmpty(); + } + + @Test + void should_return_empty_map_when_keys_is_empty() { + // given + List keys = List.of(); + + // when + Map result = entityLookupService.findCards(keys); + + // then + assertThat(result).isEmpty(); + } +} From c245aa946807404ddd696c249c6294899a5aa7f3 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:17:56 +0900 Subject: [PATCH 03/54] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EA=B2=80=EC=83=89(=EB=A9=98=EC=85=98=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=99=84=EC=84=B1)=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게시글 작성 중 공연/아티스트/음악 발매를 본문에 인라인 멘션하기 위한 자동완성 API를 추가한다. type별로 기존 검색 로직(Concert 제목, Artist name/alias, ReleaseGroup 검색 쿼리)을 재사용하고 결과를 EntityLookupService의 카드 변환 로직으로 통일한다. - GET /api/mentions/search?type=&q=&limit= (인증 불필요) - ConcertRepository.searchByTitleForMention 추가 - EntityLookupService.toCard(...)를 패키지 접근으로 열어 MentionService에서 재사용 - SecurityConfig: GET /api/mentions/search permitAll 추가 - MentionService/MentionControllerTest: 타입별 위임·빈 결과 처리 검증 (컨트롤러 테스트는 프로젝트 컨벤션인 standaloneSetup + LocalValidatorFactoryBean 사용) 이슈 #116 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/config/SecurityConfig.java | 3 +- .../concert/repository/ConcertRepository.java | 3 + .../post/controller/MentionController.java | 40 ++++++ .../post/service/EntityLookupService.java | 6 +- .../Backend/post/service/MentionService.java | 77 +++++++++++ .../controller/MentionControllerTest.java | 111 ++++++++++++++++ .../post/service/MentionServiceTest.java | 123 ++++++++++++++++++ 7 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/post/controller/MentionController.java create mode 100644 src/main/java/com/Coming/Backend/post/service/MentionService.java create mode 100644 src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index bf2a072..6661b32 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -118,7 +118,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/artists/**", "/api/concerts/**", "/api/releases/**", - "/api/calendar" + "/api/calendar", + "/api/mentions/search" ).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") diff --git a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java index b856be4..af24102 100644 --- a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java +++ b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java @@ -35,6 +35,9 @@ public interface ConcertRepository extends JpaRepository { @Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id") void incrementViewCount(@Param("id") Long id); + @Query("SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC") + List searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable); + @Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC") List findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection hidden); diff --git a/src/main/java/com/Coming/Backend/post/controller/MentionController.java b/src/main/java/com/Coming/Backend/post/controller/MentionController.java new file mode 100644 index 0000000..68ee3b6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/MentionController.java @@ -0,0 +1,40 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.MentionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/mentions") +@RequiredArgsConstructor +public class MentionController { + + private final MentionService mentionService; + + @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성)") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 limit 범위 초과)") + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam EntityType type, + @RequestParam @NotBlank String q, + @RequestParam(defaultValue = "10") @Min(1) @Max(20) int limit) { + return ResponseEntity.ok(mentionService.search(type, q, limit)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java index 483198a..b65d850 100644 --- a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -59,16 +59,16 @@ private Set idsOf(Collection keys, EntityType type) { .collect(Collectors.toSet()); } - private EntityCardResponse toCard(Concert concert) { + EntityCardResponse toCard(Concert concert) { String subtitle = concert.getStartDate() + " · " + concert.getVenueName(); return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl()); } - private EntityCardResponse toCard(Artist artist) { + EntityCardResponse toCard(Artist artist) { return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl()); } - private EntityCardResponse toCard(ReleaseGroup release, String artistName) { + EntityCardResponse toCard(ReleaseGroup release, String artistName) { return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl()); } diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java new file mode 100644 index 0000000..0609cd6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -0,0 +1,77 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.concert.entity.ConcertStatus; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.Coming.Backend.concert.entity.ConcertStatus.EXCLUDED; +import static com.Coming.Backend.concert.entity.ConcertStatus.PENDING; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MentionService { + + private static final List HIDDEN_STATUSES = List.of(EXCLUDED, PENDING); + + private final ConcertRepository concertRepository; + private final ArtistRepository artistRepository; + private final ReleaseGroupRepository releaseGroupRepository; + private final EntityLookupService entityLookupService; + + /** + * 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 최대 limit건 검색한다. + */ + public List search(EntityType type, String q, int limit) { + Pageable pageable = PageRequest.of(0, limit); + return switch (type) { + case CONCERT -> searchConcerts(q, pageable); + case ARTIST -> searchArtists(q, pageable); + case RELEASE -> searchReleases(q, pageable); + }; + } + + private List searchConcerts(String q, Pageable pageable) { + String likeQ = toLikePattern(q); + return concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable).stream() + .map(entityLookupService::toCard) + .toList(); + } + + private List searchArtists(String q, Pageable pageable) { + return artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable).stream() + .map(entityLookupService::toCard) + .toList(); + } + + private List searchReleases(String q, Pageable pageable) { + List releases = releaseGroupRepository + .searchReleases(null, null, null, toLikePattern(q), pageable) + .getContent(); + Map artistNames = artistRepository.findAllById( + releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + return releases.stream() + .map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId()))) + .toList(); + } + + private String toLikePattern(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java new file mode 100644 index 0000000..154438f --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java @@ -0,0 +1,111 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.MentionService; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class MentionControllerTest { + + private MockMvc mockMvc; + + @Mock + private MentionService mentionService; + + @InjectMocks + private MentionController mentionController; + + private static final Long CONCERT_ID = 1L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(mentionController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + @Test + void should_return_200_with_entity_card_list_when_valid_request_given() throws Exception { + // given + EntityCardResponse card = new EntityCardResponse( + EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null); + given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(10))).willReturn(List.of(card)); + + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("type", "CONCERT") + .param("q", "아이유") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].type").value("CONCERT")) + .andExpect(jsonPath("$[0].id").value(CONCERT_ID)) + .andExpect(jsonPath("$[0].title").value("아이유 콘서트")); + } + + @Test + void should_return_400_when_type_is_missing() throws Exception { + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("q", "아이유") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_type_is_invalid_value() throws Exception { + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("type", "INVALID_TYPE") + .param("q", "아이유") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_200_with_empty_array_when_no_search_result_found() throws Exception { + // given + given(mentionService.search(eq(EntityType.ARTIST), eq("없는아티스트"), any(Integer.class))) + .willReturn(List.of()); + + // when & then + mockMvc.perform(get("/api/mentions/search") + .param("type", "ARTIST") + .param("q", "없는아티스트") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java new file mode 100644 index 0000000..bf00edf --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -0,0 +1,123 @@ +package com.Coming.Backend.post.service; + +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; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class MentionServiceTest { + + @InjectMocks + private MentionService mentionService; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ArtistRepository artistRepository; + + @Mock + private ReleaseGroupRepository releaseGroupRepository; + + @Mock + private EntityLookupService entityLookupService; + + private static final int LIMIT = 10; + private static final Long CONCERT_ID = 1L; + private static final Long ARTIST_ID = 10L; + private static final Long RELEASE_ID = 100L; + private static final Long RELEASE_ARTIST_ID = 20L; + + @Test + void should_return_concert_cards_when_type_is_CONCERT() { + // given + String q = "아이유"; + Concert concert = Concert.builder().id(CONCERT_ID).title("아이유 콘서트").build(); + EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null); + given(concertRepository.searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class))) + .willReturn(List.of(concert)); + given(entityLookupService.toCard(concert)).willReturn(card); + + // when + List result = mentionService.search(EntityType.CONCERT, q, LIMIT); + + // then + verify(concertRepository).searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class)); + assertThat(result).containsExactly(card); + } + + @Test + void should_call_artist_repository_with_raw_query_when_type_is_ARTIST() { + // given + String q = "아이유"; + Artist artist = Artist.builder().id(ARTIST_ID).name("아이유").build(); + Page page = new PageImpl<>(List.of(artist)); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, ARTIST_ID, "아이유", null, null); + given(artistRepository.findByNameOrAliasContainingIgnoreCase(eq(q), any(Pageable.class))).willReturn(page); + given(entityLookupService.toCard(artist)).willReturn(card); + + // when + List result = mentionService.search(EntityType.ARTIST, q, LIMIT); + + // then + verify(artistRepository).findByNameOrAliasContainingIgnoreCase(eq("아이유"), any(Pageable.class)); + assertThat(result).containsExactly(card); + } + + @Test + void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { + // given + String q = "LILAC"; + ReleaseGroup release = ReleaseGroup.builder().id(RELEASE_ID).artistId(RELEASE_ARTIST_ID).title("LILAC").build(); + Artist releaseArtist = Artist.builder().id(RELEASE_ARTIST_ID).name("IU").build(); + Page page = new PageImpl<>(List.of(release)); + EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null); + given(releaseGroupRepository.searchReleases(eq(null), eq(null), eq(null), eq("%lilac%"), any(Pageable.class))) + .willReturn(page); + given(artistRepository.findAllById(eq(Set.of(RELEASE_ARTIST_ID)))).willReturn(List.of(releaseArtist)); + given(entityLookupService.toCard(release, "IU")).willReturn(card); + + // when + List result = mentionService.search(EntityType.RELEASE, q, LIMIT); + + // then + verify(artistRepository).findAllById(eq(Set.of(RELEASE_ARTIST_ID))); + assertThat(result).containsExactly(card); + } + + @Test + void should_return_empty_list_when_no_search_result_found() { + // given + given(concertRepository.searchByTitleForMention(any(), eq("%없는공연%"), any(Pageable.class))) + .willReturn(List.of()); + + // when + List result = mentionService.search(EntityType.CONCERT, "없는공연", LIMIT); + + // then + assertThat(result).isEmpty(); + } +} From 0bc3fea556ccf6c67198767f4cf42c98c7e6765b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:38:36 +0900 Subject: [PATCH 04/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20CR?= =?UTF-8?q?UD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게시글 작성·조회·수정·삭제 API를 구현한다. Tiptap JSON 본문은 Spring Boot 4의 기본 HTTP 컨버터가 Jackson 3(tools.jackson) 기반이라 Jackson 2 전용 타입(JsonNode/@JsonRawValue)을 요청·응답 DTO에 직접 쓸 수 없어 Object(런타임에는 Map/List)로 받고, 저장·응답 시 컨트롤러 계층과 무관한 별도의 Jackson 2 ObjectMapper로 문자열 변환한다. - POST /api/posts, GET /api/posts/{id}, GET /api/posts, PATCH /api/posts/{id}, DELETE /api/posts/{id} - REVIEW/INFO 카테고리는 entityTags 1개 이상 필수(생성·수정 모두 최종 category 기준으로 검증), entityTags는 주어지면 전체 교체 - PostDetailResponse.isAuthor(비인증 시 false), isRecommended(비인증 시 null) — 인증 여부에 따라 다른 널 규약을 의도적으로 적용 - 조회 시 viewCount +1 (Concert.viewCount와 동일한 벌크 UPDATE 패턴) - TiptapTextExtractor: Tiptap 문서 트리에서 텍스트 노드만 추출해 content_text(검색용 파생 컬럼)를 만든다 - SecurityConfig: GET /api/posts, GET /api/posts/** permitAll 추가 - PostService/PostController/TiptapTextExtractor 테스트 추가 (컨트롤러 테스트는 standaloneSetup + AuthenticationPrincipalArgumentResolver로 인증 사용자 시뮬레이션, Jackson 3 기본 컨버터가 테스트에서 쓰는 JsonNode/Object 필드를 못 읽어 MappingJackson2HttpMessageConverter를 명시 등록) 이슈 #116 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/config/SecurityConfig.java | 4 +- .../post/controller/PostController.java | 91 ++++ .../Backend/post/dto/EntityTagRequest.java | 13 + .../Backend/post/dto/PostCreateRequest.java | 24 + .../Backend/post/dto/PostCreateResponse.java | 4 + .../Backend/post/dto/PostDetailResponse.java | 22 + .../post/dto/PostEntityTagResponse.java | 15 + .../Backend/post/dto/PostSummaryResponse.java | 18 + .../Backend/post/dto/PostUpdateRequest.java | 19 + .../com/Coming/Backend/post/entity/Post.java | 11 + .../exception/PostForbiddenException.java | 11 + .../repository/PostEntityTagRepository.java | 9 + .../repository/PostRecommendRepository.java | 2 + .../post/repository/PostRepository.java | 13 + .../Backend/post/service/PostService.java | 243 +++++++++ .../post/util/TiptapTextExtractor.java | 42 ++ .../post/controller/PostControllerTest.java | 238 +++++++++ .../Backend/post/service/PostServiceTest.java | 487 ++++++++++++++++++ .../post/util/TiptapTextExtractorTest.java | 94 ++++ 19 files changed, 1359 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Coming/Backend/post/controller/PostController.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java create mode 100644 src/main/java/com/Coming/Backend/post/service/PostService.java create mode 100644 src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java create mode 100644 src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/post/service/PostServiceTest.java create mode 100644 src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index 6661b32..75c97d7 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -119,7 +119,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/concerts/**", "/api/releases/**", "/api/calendar", - "/api/mentions/search" + "/api/mentions/search", + "/api/posts/**", + "/api/posts" ).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java new file mode 100644 index 0000000..1968174 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -0,0 +1,91 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + @Operation(summary = "게시글 작성") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (REVIEW/INFO 카테고리에 entityTags 없음)") + @PostMapping + public ResponseEntity create( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PostCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, request)); + } + + @Operation(summary = "게시글 상세 조회") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @GetMapping("/{id}") + public ResponseEntity getDetail( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(postService.getDetail(id, userId)); + } + + @Operation(summary = "게시글 목록 조회") + @GetMapping + public ResponseEntity> getList( + @RequestParam(required = false) PostCategory category, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.getList(category, page, size)); + } + + @Operation(summary = "게시글 수정") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (REVIEW/INFO 카테고리에 entityTags 없음)") + @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @PatchMapping("/{id}") + public ResponseEntity update( + @AuthenticationPrincipal Long userId, + @PathVariable Long id, + @Valid @RequestBody PostUpdateRequest request) { + postService.update(userId, id, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "게시글 삭제") + @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @DeleteMapping("/{id}") + public ResponseEntity delete( + @AuthenticationPrincipal Long userId, + @PathVariable Long id) { + postService.delete(userId, id); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java new file mode 100644 index 0000000..39dee73 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java @@ -0,0 +1,13 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; +import jakarta.validation.constraints.NotNull; + +public record EntityTagRequest( + @NotNull + EntityType entityType, + + @NotNull + Long entityId +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..4b7e946 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java @@ -0,0 +1,24 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PostCreateRequest( + @NotNull + PostCategory category, + + @NotBlank + @Size(max = 255) + String title, + + @NotNull + Object content, + + List<@Valid EntityTagRequest> entityTags +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java new file mode 100644 index 0000000..8f94de8 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record PostCreateResponse(Long id) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..7b06583 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java @@ -0,0 +1,22 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostDetailResponse( + Long id, + String authorNickname, + PostCategory category, + String title, + Object content, + List entityTags, + Long recommendCount, + Long viewCount, + Boolean isRecommended, + boolean isAuthor, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java new file mode 100644 index 0000000..a9bd57c --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java @@ -0,0 +1,15 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; + +public record PostEntityTagResponse( + EntityType entityType, + Long entityId, + String title, + String subtitle, + String thumbnailUrl +) { + public static PostEntityTagResponse of(EntityType entityType, Long entityId, EntityCardResponse card) { + return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl()); + } +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java new file mode 100644 index 0000000..fe3370c --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java @@ -0,0 +1,18 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostSummaryResponse( + Long id, + String authorNickname, + PostCategory category, + String title, + List entityTags, + Long recommendCount, + Long viewCount, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..200b756 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java @@ -0,0 +1,19 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.PostCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PostUpdateRequest( + PostCategory category, + + @Size(max = 255) + String title, + + Object content, + + List<@Valid EntityTagRequest> entityTags +) { +} diff --git a/src/main/java/com/Coming/Backend/post/entity/Post.java b/src/main/java/com/Coming/Backend/post/entity/Post.java index 3a6528d..177cfbb 100644 --- a/src/main/java/com/Coming/Backend/post/entity/Post.java +++ b/src/main/java/com/Coming/Backend/post/entity/Post.java @@ -51,4 +51,15 @@ public class Post extends BaseTimeEntity { @Column(name = "view_count", nullable = false) private Long viewCount; + + public boolean isAuthoredBy(Long userId) { + return userId != null && this.userId.equals(userId); + } + + public void update(PostCategory category, String title, String content, String contentText) { + if (category != null) this.category = category; + if (title != null) this.title = title; + if (content != null) this.content = content; + if (contentText != null) this.contentText = contentText; + } } diff --git a/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java new file mode 100644 index 0000000..2a9adb7 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PostForbiddenException extends BusinessException { + + public PostForbiddenException() { + super(ErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java index 41570e2..432f88f 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java @@ -3,5 +3,14 @@ import com.Coming.Backend.post.entity.PostEntityTag; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.List; + public interface PostEntityTagRepository extends JpaRepository { + + List findByPostId(Long postId); + + List findByPostIdIn(Collection postIds); + + void deleteByPostId(Long postId); } diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java index 72c984c..89f8aed 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface PostRecommendRepository extends JpaRepository { + + boolean existsByUserIdAndPostId(Long userId, Long postId); } diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index eea61ec..94a8c8f 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -1,7 +1,20 @@ package com.Coming.Backend.post.repository; import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostRepository extends JpaRepository { + + @Query("SELECT p FROM Post p WHERE (:category IS NULL OR p.category = :category) ORDER BY p.createdAt DESC") + Page findPosts(@Param("category") PostCategory category, Pageable pageable); + + @Modifying + @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") + void incrementViewCount(@Param("id") Long id); } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java new file mode 100644 index 0000000..68d73f0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -0,0 +1,243 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.dto.EntityTagRequest; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostEntityTagResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.post.exception.PostForbiddenException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.PostEntityTagRepository; +import com.Coming.Backend.post.repository.PostRecommendRepository; +import com.Coming.Backend.post.repository.PostRepository; +import com.Coming.Backend.post.util.TiptapTextExtractor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + private final PostEntityTagRepository postEntityTagRepository; + private final PostRecommendRepository postRecommendRepository; + private final UserRepository userRepository; + private final EntityLookupService entityLookupService; + + /** + * content 컬럼(jsonb)의 String ↔ Object 변환 전용. Spring이 HTTP 메시지 변환에 사용하는 + * Jackson 인스턴스(3.x)와 무관하게 항상 Jackson 2 databind로 직렬화·역직렬화한다. + */ + private final ObjectMapper contentObjectMapper = new ObjectMapper(); + + /** + * 게시글을 생성한다. REVIEW·INFO 카테고리는 entityTags가 1개 이상 있어야 한다. + * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 + * 조회 시점에 EntityLookupService가 조용히 제외한다. + */ + @Transactional + public PostCreateResponse create(Long userId, PostCreateRequest request) { + List tags = request.entityTags() == null ? List.of() : request.entityTags(); + validateEntityTagsRequired(request.category(), tags.size()); + + Post post = Post.builder() + .userId(userId) + .category(request.category()) + .title(request.title()) + .content(writeContent(request.content())) + .contentText(TiptapTextExtractor.extract(request.content())) + .recommendCount(0L) + .viewCount(0L) + .build(); + postRepository.save(post); + saveEntityTags(post.getId(), tags); + + return new PostCreateResponse(post.getId()); + } + + /** + * 게시글 상세를 조회한다. 조회할 때마다 viewCount가 1 증가한다(중복 조회 방지 없음). + * + * @param userId 인증 사용자 ID. null이면 isRecommended는 null, isAuthor는 false로 반환된다. + */ + @Transactional + public PostDetailResponse getDetail(Long id, Long userId) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + postRepository.incrementViewCount(id); + + List tags = postEntityTagRepository.findByPostId(id); + List entityTags = mapEntityTagsByPost(tags).getOrDefault(id, List.of()); + + String authorNickname = userRepository.findById(post.getUserId()) + .map(User::getNickname) + .orElse(null); + Boolean isRecommended = userId == null ? null : postRecommendRepository.existsByUserIdAndPostId(userId, id); + + return new PostDetailResponse( + post.getId(), + authorNickname, + post.getCategory(), + post.getTitle(), + readContent(post.getContent()), + entityTags, + post.getRecommendCount(), + post.getViewCount() + 1, + isRecommended, + post.isAuthoredBy(userId), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } + + /** + * 게시글 목록을 최신순으로 조회한다. + * + * @param category null이면 전체 카테고리 + */ + public PageResponse getList(PostCategory category, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page result = postRepository.findPosts(category, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + + /** + * Post 목록을 PostSummaryResponse 목록으로 변환한다. entityTags·authorNickname을 배치 조회해 채운다. + * 백링크·통합검색 등 다른 조회 API에서도 재사용한다. + */ + public List toSummaryResponses(List posts) { + if (posts.isEmpty()) { + return List.of(); + } + List postIds = posts.stream().map(Post::getId).toList(); + Map> tagsByPost = + mapEntityTagsByPost(postEntityTagRepository.findByPostIdIn(postIds)); + Map nicknameByUserId = userRepository.findAllByIdIn( + posts.stream().map(Post::getUserId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(User::getId, User::getNickname)); + + return posts.stream().map(post -> new PostSummaryResponse( + post.getId(), + nicknameByUserId.get(post.getUserId()), + post.getCategory(), + post.getTitle(), + tagsByPost.getOrDefault(post.getId(), List.of()), + post.getRecommendCount(), + post.getViewCount(), + post.getCreatedAt() + )).toList(); + } + + /** + * 게시글을 수정한다. 작성자 본인만 수정할 수 있다. + * category·title·content는 null이면 기존값을 유지하고, entityTags는 null이면 기존 태그를 유지한다. + * entityTags가 주어지면 기존 태그를 전체 삭제 후 재삽입한다. + * 수정 후 최종 category가 REVIEW·INFO면 최종 entityTags가 1개 이상이어야 한다. + */ + @Transactional + public void update(Long userId, Long id, PostUpdateRequest request) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + if (!post.isAuthoredBy(userId)) { + throw new PostForbiddenException(); + } + + PostCategory effectiveCategory = request.category() != null ? request.category() : post.getCategory(); + int effectiveTagCount = request.entityTags() != null + ? request.entityTags().size() + : postEntityTagRepository.findByPostId(id).size(); + validateEntityTagsRequired(effectiveCategory, effectiveTagCount); + + String content = request.content() != null ? writeContent(request.content()) : null; + String contentText = request.content() != null ? TiptapTextExtractor.extract(request.content()) : null; + post.update(request.category(), request.title(), content, contentText); + + if (request.entityTags() != null) { + postEntityTagRepository.deleteByPostId(id); + saveEntityTags(id, request.entityTags()); + } + } + + /** + * 게시글을 삭제한다. 작성자 본인만 삭제할 수 있다. + */ + @Transactional + public void delete(Long userId, Long id) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + if (!post.isAuthoredBy(userId)) { + throw new PostForbiddenException(); + } + postEntityTagRepository.deleteByPostId(id); + postRepository.delete(post); + } + + private String writeContent(Object content) { + try { + return contentObjectMapper.writeValueAsString(content); + } catch (JsonProcessingException e) { + throw new IllegalStateException("게시글 content 직렬화에 실패했습니다.", e); + } + } + + private Object readContent(String content) { + try { + return contentObjectMapper.readValue(content, Object.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("저장된 게시글 content가 유효한 JSON이 아닙니다.", e); + } + } + + private void validateEntityTagsRequired(PostCategory category, int tagCount) { + boolean requiresTags = category == PostCategory.REVIEW || category == PostCategory.INFO; + if (requiresTags && tagCount == 0) { + throw new InvalidInputException(); + } + } + + private void saveEntityTags(Long postId, List tags) { + tags.forEach(tag -> postEntityTagRepository.save(PostEntityTag.builder() + .postId(postId) + .entityType(tag.entityType()) + .entityId(tag.entityId()) + .build())); + } + + private Map> mapEntityTagsByPost(List tags) { + if (tags.isEmpty()) { + return Map.of(); + } + List keys = tags.stream() + .map(tag -> new EntityLookupService.EntityKey(tag.getEntityType(), tag.getEntityId())) + .toList(); + Map cards = entityLookupService.findCards(keys); + + return tags.stream() + .filter(tag -> cards.containsKey(new EntityLookupService.EntityKey(tag.getEntityType(), tag.getEntityId()))) + .collect(Collectors.groupingBy( + PostEntityTag::getPostId, + Collectors.mapping( + tag -> PostEntityTagResponse.of(tag.getEntityType(), tag.getEntityId(), + cards.get(new EntityLookupService.EntityKey(tag.getEntityType(), tag.getEntityId()))), + Collectors.toList()))); + } +} diff --git a/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java new file mode 100644 index 0000000..31ef2b5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java @@ -0,0 +1,42 @@ +package com.Coming.Backend.post.util; + +import java.util.List; +import java.util.Map; + +public final class TiptapTextExtractor { + + private TiptapTextExtractor() { + } + + /** + * Tiptap 문서(JSON을 역직렬화한 Map/List 트리)에서 텍스트 노드만 추출해 공백으로 이어붙인 + * 검색용 텍스트를 반환한다. 멘션 등 텍스트 노드가 아닌 노드는 무시한다. + */ + public static String extract(Object content) { + StringBuilder builder = new StringBuilder(); + collect(content, builder); + return builder.toString().trim(); + } + + private static void collect(Object node, StringBuilder builder) { + if (node instanceof List list) { + list.forEach(child -> collect(child, builder)); + return; + } + if (!(node instanceof Map map)) { + return; + } + Object text = map.get("text"); + if (isTextNode(map) && text instanceof String textValue) { + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(textValue); + } + collect(map.get("content"), builder); + } + + private static boolean isTextNode(Map node) { + return "text".equals(node.get("type")); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java new file mode 100644 index 0000000..f7a3ba2 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -0,0 +1,238 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.exception.PostForbiddenException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.service.PostService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PostControllerTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @InjectMocks + private PostController postController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long POST_ID = 10L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(postController) + .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private Map sampleContent() { + return Map.of("type", "doc", "content", List.of()); + } + + // ------------------------------------------------------------------------- + // POST /api/posts + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_create_request_is_valid() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), null); + given(postService.create(eq(USER_ID), any(PostCreateRequest.class))).willReturn(new PostCreateResponse(POST_ID)); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(POST_ID)); + } + + @Test + void should_return_400_when_service_throws_invalid_input_exception_on_create() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), null); + given(postService.create(eq(USER_ID), any(PostCreateRequest.class))).willThrow(new InvalidInputException()); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_post_detail_when_post_exists() throws Exception { + // given + PostDetailResponse detail = new PostDetailResponse( + POST_ID, "IU", PostCategory.FREE, "제목", sampleContent(), + List.of(), 0L, 1L, null, true, LocalDateTime.now(), LocalDateTime.now() + ); + given(postService.getDetail(eq(POST_ID), eq(USER_ID))).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/posts/{id}", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(POST_ID)) + .andExpect(jsonPath("$.title").value("제목")); + } + + @Test + void should_return_404_when_post_not_found_on_get_detail() throws Exception { + // given + given(postService.getDetail(eq(999L), eq(USER_ID))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(get("/api/posts/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/posts + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_page_response_when_getting_list() throws Exception { + // given + PostSummaryResponse summary = new PostSummaryResponse( + POST_ID, "IU", PostCategory.FREE, "제목", List.of(), 0L, 0L, LocalDateTime.now()); + PageResponse pageResponse = new PageResponse<>(List.of(summary), 0, 20, 1, 1); + given(postService.getList(isNull(), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(POST_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + // ------------------------------------------------------------------------- + // PATCH /api/posts/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_update_request_is_valid() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", null, null); + willDoNothing().given(postService).update(eq(USER_ID), eq(POST_ID), any(PostUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void should_return_403_when_service_throws_forbidden_exception_on_update() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", null, null); + willThrow(new PostForbiddenException()).given(postService) + .update(eq(USER_ID), eq(POST_ID), any(PostUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/posts/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_delete_succeeds() throws Exception { + // given + willDoNothing().given(postService).delete(eq(USER_ID), eq(POST_ID)); + + // when & then + mockMvc.perform(delete("/api/posts/{id}", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void should_return_404_when_post_not_found_on_delete() throws Exception { + // given + willThrow(new PostNotFoundException()).given(postService).delete(eq(USER_ID), eq(999L)); + + // when & then + mockMvc.perform(delete("/api/posts/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java new file mode 100644 index 0000000..acd01db --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -0,0 +1,487 @@ +package com.Coming.Backend.post.service; + +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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityCardResponse; +import com.Coming.Backend.post.dto.EntityTagRequest; +import com.Coming.Backend.post.dto.PostCreateRequest; +import com.Coming.Backend.post.dto.PostCreateResponse; +import com.Coming.Backend.post.dto.PostDetailResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.post.exception.PostForbiddenException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.PostEntityTagRepository; +import com.Coming.Backend.post.repository.PostRecommendRepository; +import com.Coming.Backend.post.repository.PostRepository; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @InjectMocks + private PostService postService; + + @Mock + private PostRepository postRepository; + + @Mock + private PostEntityTagRepository postEntityTagRepository; + + @Mock + private PostRecommendRepository postRecommendRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private EntityLookupService entityLookupService; + + private static final Long POST_ID = 10L; + private static final Long AUTHOR_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + + /** + * Spring이 실제로 @RequestBody를 역직렬화할 때 만드는 것과 동일한 형태(Map/List)의 Tiptap 문서. + * "안녕하세요" 텍스트 노드 하나를 포함한다. + */ + private Map sampleContent() { + return Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "안녕하세요") + )) + ) + ); + } + + private Post buildPost(Long id, Long userId, PostCategory category, String title, long viewCount) { + return Post.builder() + .id(id) + .userId(userId) + .category(category) + .title(title) + .content("{\"type\":\"doc\"}") + .contentText("기존 텍스트") + .recommendCount(0L) + .viewCount(viewCount) + .build(); + } + + private User buildUser(Long id, String nickname) { + return User.builder().id(id).nickname(nickname).build(); + } + + // ------------------------------------------------------------------------- + // create + // ------------------------------------------------------------------------- + + @Test + void should_throw_invalid_input_exception_when_review_category_given_without_entity_tags() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), null); + + // when & then + assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + } + + @Test + void should_throw_invalid_input_exception_when_info_category_given_without_entity_tags() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.INFO, "정보 제목", sampleContent(), List.of()); + + // when & then + assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + } + + @Test + void should_create_post_when_free_category_given_without_entity_tags() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), null); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); + verify(postRepository).save(captor.capture()); + assertThat(captor.getValue().getContent()).contains("\"type\":\"doc\""); + assertThat(captor.getValue().getContentText()).isEqualTo("안녕하세요"); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); + } + + @Test + void should_save_entity_tags_when_review_category_given_with_entity_tags() { + // given + List tags = List.of( + new EntityTagRequest(EntityType.ARTIST, 1L), + new EntityTagRequest(EntityType.CONCERT, 2L) + ); + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), tags); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postEntityTagRepository, times(2)).save(any(PostEntityTag.class)); + } + + // ------------------------------------------------------------------------- + // getDetail + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_post_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.getDetail(POST_ID, AUTHOR_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_increment_view_count_and_return_incremented_view_count_when_post_found() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 5L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + verify(postRepository).incrementViewCount(POST_ID); + assertThat(response.viewCount()).isEqualTo(6L); + } + + @Test + void should_return_null_recommended_and_false_author_when_user_id_not_given() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.isRecommended()).isNull(); + assertThat(response.isAuthor()).isFalse(); + verify(postRecommendRepository, never()).existsByUserIdAndPostId(any(), any()); + } + + @Test + void should_return_true_author_when_user_id_matches_post_author() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + given(postRecommendRepository.existsByUserIdAndPostId(AUTHOR_ID, POST_ID)).willReturn(false); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, AUTHOR_ID); + + // then + assertThat(response.isAuthor()).isTrue(); + } + + @Test + void should_return_true_recommended_when_user_has_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(true); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, OTHER_USER_ID); + + // then + assertThat(response.isRecommended()).isTrue(); + } + + @Test + void should_return_false_recommended_when_user_has_not_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, OTHER_USER_ID); + + // then + assertThat(response.isRecommended()).isFalse(); + } + + @Test + void should_map_entity_tags_using_entity_lookup_service_when_post_found() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + PostEntityTag tag = PostEntityTag.builder() + .postId(POST_ID) + .entityType(EntityType.ARTIST) + .entityId(1L) + .build(); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of(tag)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.entityTags()).hasSize(1); + assertThat(response.entityTags().get(0).entityType()).isEqualTo(EntityType.ARTIST); + assertThat(response.entityTags().get(0).entityId()).isEqualTo(1L); + assertThat(response.entityTags().get(0).title()).isEqualTo("IU"); + } + + // ------------------------------------------------------------------------- + // getList + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_when_getting_list() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 3L); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findPosts(isNull(), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getList(null, 0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("제목"); + assertThat(response.page()).isEqualTo(0); + assertThat(response.size()).isEqualTo(20); + assertThat(response.totalElements()).isEqualTo(1); + } + + // ------------------------------------------------------------------------- + // update + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_updating_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when & then + assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_post_forbidden_exception_when_updater_is_not_author() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when & then + assertThatThrownBy(() -> postService.update(OTHER_USER_ID, POST_ID, request)) + .isInstanceOf(PostForbiddenException.class) + .hasMessage(ErrorCode.FORBIDDEN.getMessage()); + } + + @Test + void should_update_title_and_content_when_given_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", sampleContent(), null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getTitle()).isEqualTo("새 제목"); + assertThat(post.getContent()).contains("\"type\":\"doc\""); + assertThat(post.getContentText()).isEqualTo("안녕하세요"); + } + + @Test + void should_keep_existing_title_and_content_when_null_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getTitle()).isEqualTo("기존 제목"); + assertThat(post.getContent()).isEqualTo("{\"type\":\"doc\"}"); + } + + @Test + void should_not_touch_entity_tags_when_entity_tags_is_null_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + verify(postEntityTagRepository, never()).deleteByPostId(POST_ID); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); + } + + @Test + void should_replace_entity_tags_when_entity_tags_given_in_request() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + List newTags = List.of( + new EntityTagRequest(EntityType.ARTIST, 1L), + new EntityTagRequest(EntityType.CONCERT, 2L) + ); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, newTags); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + verify(postEntityTagRepository).deleteByPostId(POST_ID); + verify(postEntityTagRepository, times(2)).save(any(PostEntityTag.class)); + } + + @Test + void should_throw_invalid_input_exception_when_effective_category_requires_tags_and_existing_tags_empty() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); + PostUpdateRequest request = new PostUpdateRequest(PostCategory.REVIEW, null, null, null); + + // when & then + assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + assertThat(post.getCategory()).isEqualTo(PostCategory.FREE); + } + + @Test + void should_throw_invalid_input_exception_when_effective_category_requires_tags_and_request_tags_empty() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, null, List.of()); + + // when & then + assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + } + + // ------------------------------------------------------------------------- + // delete + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_deleting_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.delete(AUTHOR_ID, POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_post_forbidden_exception_when_deleter_is_not_author() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when & then + assertThatThrownBy(() -> postService.delete(OTHER_USER_ID, POST_ID)) + .isInstanceOf(PostForbiddenException.class) + .hasMessage(ErrorCode.FORBIDDEN.getMessage()); + } + + @Test + void should_delete_post_and_entity_tags_when_author_deletes() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when + postService.delete(AUTHOR_ID, POST_ID); + + // then + verify(postEntityTagRepository).deleteByPostId(POST_ID); + verify(postRepository).delete(post); + } +} diff --git a/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java new file mode 100644 index 0000000..9dd2d41 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java @@ -0,0 +1,94 @@ +package com.Coming.Backend.post.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TiptapTextExtractorTest { + + @Test + void should_extract_single_text_node() { + // given + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "안녕하세요") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("안녕하세요"); + } + + @Test + void should_join_multiple_text_nodes_with_space() { + // given + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "첫 문단"), + Map.of("type", "text", "text", "이어지는 텍스트") + )), + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "둘째 문단") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("첫 문단 이어지는 텍스트 둘째 문단"); + } + + @Test + void should_ignore_non_text_nodes_such_as_mention() { + // given + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "공연"), + Map.of("type", "mention", "attrs", Map.of("entityType", "CONCERT", "entityId", 1)), + Map.of("type", "text", "text", "다녀왔어요") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("공연 다녀왔어요"); + } + + @Test + void should_return_empty_string_when_no_text_node_exists() { + // given + Map content = Map.of("type", "doc", "content", List.of()); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEmpty(); + } + + @Test + void should_return_empty_string_when_content_is_null() { + // when + String result = TiptapTextExtractor.extract(null); + + // then + assertThat(result).isEmpty(); + } +} From d9fcc6675288462fb5f9562dec85af338b11f62d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 23:51:45 +0900 Subject: [PATCH 05/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C/=EC=B6=94=EC=B2=9C=EC=B7=A8=EC=86=8C=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArtistService.follow/unfollow와 동일한 패턴(존재 확인 -> 중복 확인 -> DataIntegrityViolationException으로 UNIQUE 제약 race condition 방어)을 따르고, recommend_count는 비정규화 카운터로 @Modifying 쿼리를 통해 증감한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/controller/PostController.java | 21 +++ .../post/dto/RecommendCountResponse.java | 4 + .../repository/PostRecommendRepository.java | 3 + .../post/repository/PostRepository.java | 8 ++ .../Backend/post/service/PostService.java | 39 ++++++ .../post/controller/PostControllerTest.java | 81 +++++++++++ .../Backend/post/service/PostServiceTest.java | 127 ++++++++++++++++++ 7 files changed, 283 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java index 1968174..e0b4a86 100644 --- a/src/main/java/com/Coming/Backend/post/controller/PostController.java +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -6,6 +6,7 @@ import com.Coming.Backend.post.dto.PostDetailResponse; import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.service.PostService; import io.swagger.v3.oas.annotations.Operation; @@ -88,4 +89,24 @@ public ResponseEntity delete( postService.delete(userId, id); return ResponseEntity.ok().build(); } + + @Operation(summary = "게시글 추천") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @ApiResponse(responseCode = "409", description = "ALREADY_RECOMMENDED") + @PostMapping("/{id}/recommend") + public ResponseEntity recommend( + @AuthenticationPrincipal Long userId, + @PathVariable Long id) { + return ResponseEntity.ok(postService.recommend(userId, id)); + } + + @Operation(summary = "게시글 추천 취소") + @ApiResponse(responseCode = "400", description = "NOT_RECOMMENDED") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @DeleteMapping("/{id}/recommend") + public ResponseEntity unrecommend( + @AuthenticationPrincipal Long userId, + @PathVariable Long id) { + return ResponseEntity.ok(postService.unrecommend(userId, id)); + } } diff --git a/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java b/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java new file mode 100644 index 0000000..74380d3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record RecommendCountResponse(Long recommendCount) { +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java index 89f8aed..a8af27a 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java @@ -1,9 +1,12 @@ package com.Coming.Backend.post.repository; import com.Coming.Backend.post.entity.PostRecommend; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PostRecommendRepository extends JpaRepository { boolean existsByUserIdAndPostId(Long userId, Long postId); + + Optional findByUserIdAndPostId(Long userId, Long postId); } diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index 94a8c8f..d7ab2a0 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -17,4 +17,12 @@ public interface PostRepository extends JpaRepository { @Modifying @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") void incrementViewCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Post p SET p.recommendCount = p.recommendCount + 1 WHERE p.id = :id") + void incrementRecommendCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Post p SET p.recommendCount = p.recommendCount - 1 WHERE p.id = :id") + void decrementRecommendCount(@Param("id") Long id); } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 68d73f0..d14e1eb 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -12,9 +12,13 @@ import com.Coming.Backend.post.dto.PostEntityTagResponse; import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.post.entity.PostRecommend; +import com.Coming.Backend.post.exception.AlreadyRecommendedException; +import com.Coming.Backend.post.exception.NotRecommendedException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; import com.Coming.Backend.post.repository.PostEntityTagRepository; @@ -24,6 +28,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -191,6 +196,40 @@ public void delete(Long userId, Long id) { postRepository.delete(post); } + /** + * 게시글을 추천한다. 이미 추천한 게시글이면 AlreadyRecommendedException을 던진다. + */ + @Transactional + public RecommendCountResponse recommend(Long userId, Long id) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + if (postRecommendRepository.existsByUserIdAndPostId(userId, id)) { + throw new AlreadyRecommendedException(); + } + try { + postRecommendRepository.save(PostRecommend.builder() + .userId(userId) + .postId(id) + .build()); + } catch (DataIntegrityViolationException e) { + throw new AlreadyRecommendedException(); + } + postRepository.incrementRecommendCount(id); + return new RecommendCountResponse(post.getRecommendCount() + 1); + } + + /** + * 게시글 추천을 취소한다. 추천한 적 없으면 NotRecommendedException을 던진다. + */ + @Transactional + public RecommendCountResponse unrecommend(Long userId, Long id) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + PostRecommend recommend = postRecommendRepository.findByUserIdAndPostId(userId, id) + .orElseThrow(NotRecommendedException::new); + postRecommendRepository.delete(recommend); + postRepository.decrementRecommendCount(id); + return new RecommendCountResponse(post.getRecommendCount() - 1); + } + private String writeContent(Object content) { try { return contentObjectMapper.writeValueAsString(content); diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index f7a3ba2..2601929 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -22,7 +22,10 @@ import com.Coming.Backend.post.dto.PostDetailResponse; import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.exception.AlreadyRecommendedException; +import com.Coming.Backend.post.exception.NotRecommendedException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; import com.Coming.Backend.post.service.PostService; @@ -235,4 +238,82 @@ void should_return_404_when_post_not_found_on_delete() throws Exception { .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) .andExpect(jsonPath("$.message").exists()); } + + // ------------------------------------------------------------------------- + // POST /api/posts/{id}/recommend + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_recommend_count_when_recommend_succeeds() throws Exception { + // given + given(postService.recommend(eq(USER_ID), eq(POST_ID))).willReturn(new RecommendCountResponse(4L)); + + // when & then + mockMvc.perform(post("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recommendCount").value(4)); + } + + @Test + void should_return_404_when_post_not_found_on_recommend() throws Exception { + // given + given(postService.recommend(eq(USER_ID), eq(999L))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(post("/api/posts/{id}/recommend", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_post_already_recommended_on_recommend() throws Exception { + // given + given(postService.recommend(eq(USER_ID), eq(POST_ID))).willThrow(new AlreadyRecommendedException()); + + // when & then + mockMvc.perform(post("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.ALREADY_RECOMMENDED.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/posts/{id}/recommend + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_recommend_count_when_unrecommend_succeeds() throws Exception { + // given + given(postService.unrecommend(eq(USER_ID), eq(POST_ID))).willReturn(new RecommendCountResponse(2L)); + + // when & then + mockMvc.perform(delete("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recommendCount").value(2)); + } + + @Test + void should_return_404_when_post_not_found_on_unrecommend() throws Exception { + // given + given(postService.unrecommend(eq(USER_ID), eq(999L))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(delete("/api/posts/{id}/recommend", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_post_not_recommended_on_unrecommend() throws Exception { + // given + given(postService.unrecommend(eq(USER_ID), eq(POST_ID))).willThrow(new NotRecommendedException()); + + // when & then + mockMvc.perform(delete("/api/posts/{id}/recommend", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOT_RECOMMENDED.name())) + .andExpect(jsonPath("$.message").exists()); + } } diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index acd01db..65b11ff 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -22,10 +22,14 @@ import com.Coming.Backend.post.dto.PostDetailResponse; import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; +import com.Coming.Backend.post.dto.RecommendCountResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.post.entity.PostRecommend; +import com.Coming.Backend.post.exception.AlreadyRecommendedException; +import com.Coming.Backend.post.exception.NotRecommendedException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; import com.Coming.Backend.post.repository.PostEntityTagRepository; @@ -41,6 +45,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -484,4 +489,126 @@ void should_delete_post_and_entity_tags_when_author_deletes() { verify(postEntityTagRepository).deleteByPostId(POST_ID); verify(postRepository).delete(post); } + + // ------------------------------------------------------------------------- + // recommend + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_recommending_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.recommend(AUTHOR_ID, POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_already_recommended_exception_when_user_already_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(true); + + // when & then + assertThatThrownBy(() -> postService.recommend(OTHER_USER_ID, POST_ID)) + .isInstanceOf(AlreadyRecommendedException.class) + .hasMessage(ErrorCode.ALREADY_RECOMMENDED.getMessage()); + verify(postRecommendRepository, never()).save(any(PostRecommend.class)); + } + + @Test + void should_throw_already_recommended_exception_when_save_violates_unique_constraint() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + given(postRecommendRepository.save(any(PostRecommend.class))) + .willThrow(new DataIntegrityViolationException("duplicate")); + + // when & then + assertThatThrownBy(() -> postService.recommend(OTHER_USER_ID, POST_ID)) + .isInstanceOf(AlreadyRecommendedException.class) + .hasMessage(ErrorCode.ALREADY_RECOMMENDED.getMessage()); + verify(postRepository, never()).incrementRecommendCount(POST_ID); + } + + @Test + void should_save_recommend_and_return_incremented_count_when_user_has_not_recommended_post() { + // given + Post post = Post.builder() + .id(POST_ID) + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title("제목") + .recommendCount(3L) + .build(); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + + // when + RecommendCountResponse response = postService.recommend(OTHER_USER_ID, POST_ID); + + // then + assertThat(response.recommendCount()).isEqualTo(4L); + verify(postRecommendRepository).save(any(PostRecommend.class)); + verify(postRepository).incrementRecommendCount(POST_ID); + } + + // ------------------------------------------------------------------------- + // unrecommend + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_unrecommending_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.unrecommend(AUTHOR_ID, POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_not_recommended_exception_when_user_has_not_recommended_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.findByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.unrecommend(OTHER_USER_ID, POST_ID)) + .isInstanceOf(NotRecommendedException.class) + .hasMessage(ErrorCode.NOT_RECOMMENDED.getMessage()); + verify(postRepository, never()).decrementRecommendCount(POST_ID); + } + + @Test + void should_delete_recommend_and_return_decremented_count_when_user_has_recommended_post() { + // given + Post post = Post.builder() + .id(POST_ID) + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title("제목") + .recommendCount(3L) + .build(); + PostRecommend recommend = PostRecommend.builder() + .userId(OTHER_USER_ID) + .postId(POST_ID) + .build(); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRecommendRepository.findByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(Optional.of(recommend)); + + // when + RecommendCountResponse response = postService.unrecommend(OTHER_USER_ID, POST_ID); + + // then + assertThat(response.recommendCount()).isEqualTo(2L); + verify(postRecommendRepository).delete(recommend); + verify(postRepository).decrementRecommendCount(POST_ID); + } } From 3accc6e2e0b5ae74f08eac7a301a17a8b532389f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 23:55:18 +0900 Subject: [PATCH 06/54] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EB=B3=84=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=B0=B1=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/entities/{type}/{id}/posts로 특정 공연·아티스트·발매에 태그된 게시글을 추천순/최신순으로 조회한다. PostEntityTag를 서브쿼리로 조인해 Post를 찾고, 기존 PostService.toSummaryResponses를 재사용해 응답을 구성한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/controller/EntityPostController.java | 41 ++++++ .../post/repository/PostRepository.java | 4 + .../Backend/post/service/PostService.java | 19 +++ .../controller/EntityPostControllerTest.java | 119 ++++++++++++++++++ .../Backend/post/service/PostServiceTest.java | 65 ++++++++++ 5 files changed, 248 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/controller/EntityPostController.java create mode 100644 src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java diff --git a/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java new file mode 100644 index 0000000..e073db3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java @@ -0,0 +1,41 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/entities") +@RequiredArgsConstructor +public class EntityPostController { + + private final PostService postService; + + @Operation(summary = "엔티티별 게시글 백링크 조회") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (허용되지 않은 sort 값)") + @GetMapping("/{type}/{id}/posts") + public ResponseEntity> getBacklinks( + @PathVariable EntityType type, + @PathVariable Long id, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.getBacklinks(type, id, sort, page, size)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index d7ab2a0..637b6bc 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.Coming.Backend.post.repository; +import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; import org.springframework.data.domain.Page; @@ -25,4 +26,7 @@ public interface PostRepository extends JpaRepository { @Modifying @Query("UPDATE Post p SET p.recommendCount = p.recommendCount - 1 WHERE p.id = :id") void decrementRecommendCount(@Param("id") Long id); + + @Query("SELECT p FROM Post p WHERE p.id IN (SELECT t.postId FROM PostEntityTag t WHERE t.entityType = :entityType AND t.entityId = :entityId)") + Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable); } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index d14e1eb..d343b68 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -13,6 +13,7 @@ import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.entity.PostEntityTag; @@ -32,6 +33,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -154,6 +156,23 @@ public List toSummaryResponses(List posts) { )).toList(); } + /** + * 특정 엔티티(공연·아티스트·발매)에 태그된 게시글을 백링크로 조회한다. + * + * @param sort "recommend"(추천순) 또는 "latest"(최신순). 그 외 값이면 InvalidInputException. + */ + public PageResponse getBacklinks(EntityType entityType, Long entityId, String sort, int page, int size) { + Sort sortOrder = switch (sort) { + case "recommend" -> Sort.by(Sort.Direction.DESC, "recommendCount"); + case "latest" -> Sort.by(Sort.Direction.DESC, "createdAt"); + default -> throw new InvalidInputException(); + }; + Pageable pageable = PageRequest.of(page, size, sortOrder); + Page result = postRepository.findByEntityTag(entityType, entityId, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + /** * 게시글을 수정한다. 작성자 본인만 수정할 수 있다. * category·title·content는 null이면 기존값을 유지하고, entityTags는 null이면 기존 태그를 유지한다. diff --git a/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java new file mode 100644 index 0000000..07f3cbf --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java @@ -0,0 +1,119 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.exception.InvalidInputException; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.service.PostService; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class EntityPostControllerTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @InjectMocks + private EntityPostController entityPostController; + + private static final Long ARTIST_ID = 1L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(entityPostController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PostSummaryResponse buildSummary() { + return new PostSummaryResponse( + 10L, "IU", PostCategory.REVIEW, "제목", List.of(), 0L, 0L, LocalDateTime.now()); + } + + @Test + void should_return_200_with_page_response_when_default_sort_given() throws Exception { + // given + PageResponse pageResponse = + new PageResponse<>(List.of(buildSummary()), 0, 20, 1, 1); + given(postService.getBacklinks(eq(EntityType.ARTIST), eq(ARTIST_ID), eq("latest"), eq(0), eq(20))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/entities/ARTIST/{id}/posts", ARTIST_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("제목")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_call_service_with_recommend_sort_when_sort_query_param_given() throws Exception { + // given + PageResponse pageResponse = + new PageResponse<>(List.of(buildSummary()), 0, 20, 1, 1); + given(postService.getBacklinks(eq(EntityType.ARTIST), eq(ARTIST_ID), eq("recommend"), eq(0), eq(20))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/entities/ARTIST/{id}/posts", ARTIST_ID) + .param("sort", "recommend") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void should_return_400_when_service_throws_invalid_input_exception() throws Exception { + // given + given(postService.getBacklinks(eq(EntityType.ARTIST), eq(ARTIST_ID), eq("oldest"), eq(0), eq(20))) + .willThrow(new InvalidInputException()); + + // when & then + mockMvc.perform(get("/api/entities/ARTIST/{id}/posts", ARTIST_ID) + .param("sort", "oldest") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_type_path_variable_is_invalid_entity_type() throws Exception { + // when & then + mockMvc.perform(get("/api/entities/INVALID/{id}/posts", ARTIST_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 65b11ff..13c8bd3 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -50,6 +50,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -327,6 +328,70 @@ void should_return_page_response_when_getting_list() { assertThat(response.totalElements()).isEqualTo(1); } + // ------------------------------------------------------------------------- + // getBacklinks + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_sorted_by_recommend_count_when_sort_is_recommend() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 3L); + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "recommendCount")); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findByEntityTag(eq(EntityType.ARTIST), eq(1L), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getBacklinks(EntityType.ARTIST, 1L, "recommend", 0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("제목"); + verify(postRepository).findByEntityTag(EntityType.ARTIST, 1L, pageable); + } + + @Test + void should_return_page_response_sorted_by_created_at_when_sort_is_latest() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 3L); + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findByEntityTag(eq(EntityType.CONCERT), eq(2L), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getBacklinks(EntityType.CONCERT, 2L, "latest", 0, 20); + + // then + assertThat(response.content()).hasSize(1); + verify(postRepository).findByEntityTag(EntityType.CONCERT, 2L, pageable); + } + + @Test + void should_throw_invalid_input_exception_when_sort_is_not_allowed_value() { + // when & then + assertThatThrownBy(() -> postService.getBacklinks(EntityType.ARTIST, 1L, "oldest", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).findByEntityTag(any(), any(), any()); + } + + @Test + void should_return_empty_content_when_no_backlinks_found() { + // given + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + given(postRepository.findByEntityTag(eq(EntityType.RELEASE), eq(3L), eq(pageable))).willReturn(Page.empty(pageable)); + + // when + PageResponse response = postService.getBacklinks(EntityType.RELEASE, 3L, "latest", 0, 20); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + // ------------------------------------------------------------------------- // update // ------------------------------------------------------------------------- From c0dd15c2b9759e6289355cf44e80dd24dcc51640 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 00:03:29 +0900 Subject: [PATCH 07/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EA=B2=80=EC=83=89=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/search로 게시글 제목·본문·태그된 엔티티명(아티스트/공연/발매)을 단일 JPQL 쿼리로 통합 검색한다. 최신순 고정. Spring Boot 4.0.6부터 @DataJpaTest가 spring-boot-starter-data-jpa-test로 분리돼 있어 테스트 의존성을 추가했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- build.gradle | 1 + .../post/controller/SearchController.java | 38 ++++++ .../post/repository/PostRepository.java | 16 +++ .../Backend/post/service/PostService.java | 12 ++ .../post/controller/SearchControllerTest.java | 85 ++++++++++++ .../post/repository/PostRepositoryTest.java | 127 ++++++++++++++++++ .../Backend/post/service/PostServiceTest.java | 37 +++++ 7 files changed, 316 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/controller/SearchController.java create mode 100644 src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java diff --git a/build.gradle b/build.gradle index 78e0038..e629fbd 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/Coming/Backend/post/controller/SearchController.java b/src/main/java/com/Coming/Backend/post/controller/SearchController.java new file mode 100644 index 0000000..a58e9cf --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/SearchController.java @@ -0,0 +1,38 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Post") +@Validated +@RestController +@RequestMapping("/api/search") +@RequiredArgsConstructor +public class SearchController { + + private final PostService postService; + + @Operation(summary = "게시글 통합 검색") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백)") + @GetMapping + public ResponseEntity> search( + @RequestParam @NotBlank String q, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.search(q, page, size)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index 637b6bc..a7d68a6 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -29,4 +29,20 @@ public interface PostRepository extends JpaRepository { @Query("SELECT p FROM Post p WHERE p.id IN (SELECT t.postId FROM PostEntityTag t WHERE t.entityType = :entityType AND t.entityId = :entityId)") Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable); + + @Query(""" + SELECT p FROM Post p + WHERE LOWER(p.title) LIKE :q OR LOWER(p.contentText) LIKE :q + OR p.id IN ( + SELECT t.postId FROM PostEntityTag t + WHERE (t.entityType = com.Coming.Backend.post.entity.EntityType.ARTIST + AND t.entityId IN (SELECT a.id FROM Artist a WHERE LOWER(a.name) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.CONCERT + AND t.entityId IN (SELECT c.id FROM Concert c WHERE LOWER(c.title) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.RELEASE + AND t.entityId IN (SELECT r.id FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q)) + ) + ORDER BY p.createdAt DESC + """) + Page searchPosts(@Param("q") String q, Pageable pageable); } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index d343b68..4bd1919 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -38,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; @@ -173,6 +174,17 @@ public PageResponse getBacklinks(EntityType entityType, Lon return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); } + /** + * 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정. + */ + public PageResponse search(String q, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + String likeQ = "%" + q.toLowerCase(Locale.ROOT) + "%"; + Page result = postRepository.searchPosts(likeQ, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + /** * 게시글을 수정한다. 작성자 본인만 수정할 수 있다. * category·title·content는 null이면 기존값을 유지하고, entityTags는 null이면 기존 태그를 유지한다. diff --git a/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java new file mode 100644 index 0000000..3d15e8f --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java @@ -0,0 +1,85 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.PostSummaryResponse; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.service.PostService; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class SearchControllerTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @InjectMocks + private SearchController searchController; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(searchController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PostSummaryResponse buildSummary() { + return new PostSummaryResponse( + 10L, "IU", PostCategory.REVIEW, "IU 콘서트 후기", List.of(), 0L, 0L, LocalDateTime.now()); + } + + @Test + void should_return_200_with_page_response_when_valid_query_given() throws Exception { + // given + PageResponse pageResponse = + new PageResponse<>(List.of(buildSummary()), 0, 20, 1, 1); + given(postService.search(eq("IU"), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/search") + .param("q", "IU") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("IU 콘서트 후기")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_return_400_when_q_is_missing() throws Exception { + // when & then + mockMvc.perform(get("/api/search") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java new file mode 100644 index 0000000..18efd6e --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -0,0 +1,127 @@ +package com.Coming.Backend.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.artist.entity.Artist; +import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.post.entity.EntityType; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.entity.PostCategory; +import com.Coming.Backend.post.entity.PostEntityTag; + +import java.util.Locale; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class PostRepositoryTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostEntityTagRepository postEntityTagRepository; + + @Autowired + private ArtistRepository artistRepository; + + private static final Long AUTHOR_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private Post buildPost(String title, String contentText) { + return Post.builder() + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title(title) + .content("{\"type\":\"doc\"}") + .contentText(contentText) + .recommendCount(0L) + .viewCount(0L) + .build(); + } + + @Test + void should_return_post_when_title_matches_search_query() { + // given + Post post = postRepository.save(buildPost("아이유 단독 콘서트 후기", "정말 좋았다")); + + // when + Page result = postRepository.searchPosts(likeQuery("아이유"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_post_when_content_text_matches_search_query() { + // given + Post post = postRepository.save(buildPost("공연 후기", "정말 재밌는 아이유 콘서트였다")); + + // when + Page result = postRepository.searchPosts(likeQuery("아이유"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_post_when_tagged_artist_name_matches_search_query() { + // given + Artist artist = artistRepository.save(Artist.builder() + .mbid(UUID.randomUUID().toString()) + .name("아이유") + .isComing(false) + .build()); + Post post = postRepository.save(buildPost("이 가수 좋아요", "최근에 알게 됐어요")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.ARTIST) + .entityId(artist.getId()) + .build()); + + // when + Page result = postRepository.searchPosts(likeQuery("아이유"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_return_empty_page_when_no_post_matches_search_query() { + // given + postRepository.save(buildPost("다른 제목", "다른 내용")); + + // when + Page result = postRepository.searchPosts(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_return_posts_ordered_by_created_at_desc_when_multiple_posts_match() throws InterruptedException { + // given + Post olderPost = postRepository.save(buildPost("아이유 1번째 글", "내용")); + Thread.sleep(10); + Post newerPost = postRepository.save(buildPost("아이유 2번째 글", "내용")); + + // when + Pageable pageable = PageRequest.of(0, 20); + Page result = postRepository.searchPosts(likeQuery("아이유"), pageable); + + // then + assertThat(result.getContent()).extracting(Post::getId) + .containsSubsequence(newerPost.getId(), olderPost.getId()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 13c8bd3..442b96b 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -392,6 +392,43 @@ void should_return_empty_content_when_no_backlinks_found() { assertThat(response.totalElements()).isZero(); } + // ------------------------------------------------------------------------- + // search + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_when_title_matches_search_query() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "IU 콘서트 후기", 3L); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.searchPosts(eq("%iu%"), eq(pageable))).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.search("IU", 0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("IU 콘서트 후기"); + verify(postRepository).searchPosts("%iu%", pageable); + } + + @Test + void should_return_empty_content_when_no_search_result_found() { + // given + Pageable pageable = PageRequest.of(0, 20); + given(postRepository.searchPosts(eq("%없는검색어%"), eq(pageable))).willReturn(Page.empty(pageable)); + + // when + PageResponse response = postService.search("없는검색어", 0, 20); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + // ------------------------------------------------------------------------- // update // ------------------------------------------------------------------------- From fa17b32c04c50242494dc91b681dd3aead91daaf Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:18:52 +0900 Subject: [PATCH 08/54] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20REV?= =?UTF-8?q?IEW/INFO=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=ED=95=84=EC=88=98=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE에서 후기/정보 카테고리의 엔티티 태그 필수 정책을 제거함에 따라 BE의 동일 서버사이드 검증도 제거해 FE-BE 불일치를 해소한다. entityTags 저장/조회 기능 자체는 유지한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/controller/PostController.java | 2 - .../Backend/post/service/PostService.java | 17 +----- .../Backend/post/service/PostServiceTest.java | 58 +++++++++---------- 3 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java index e0b4a86..e487f91 100644 --- a/src/main/java/com/Coming/Backend/post/controller/PostController.java +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -40,7 +40,6 @@ public class PostController { private final PostService postService; @Operation(summary = "게시글 작성") - @ApiResponse(responseCode = "400", description = "INVALID_INPUT (REVIEW/INFO 카테고리에 entityTags 없음)") @PostMapping public ResponseEntity create( @AuthenticationPrincipal Long userId, @@ -67,7 +66,6 @@ public ResponseEntity> getList( } @Operation(summary = "게시글 수정") - @ApiResponse(responseCode = "400", description = "INVALID_INPUT (REVIEW/INFO 카테고리에 entityTags 없음)") @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") @PatchMapping("/{id}") diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 4bd1919..861fe2b 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -60,14 +60,13 @@ public class PostService { private final ObjectMapper contentObjectMapper = new ObjectMapper(); /** - * 게시글을 생성한다. REVIEW·INFO 카테고리는 entityTags가 1개 이상 있어야 한다. + * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 * 조회 시점에 EntityLookupService가 조용히 제외한다. */ @Transactional public PostCreateResponse create(Long userId, PostCreateRequest request) { List tags = request.entityTags() == null ? List.of() : request.entityTags(); - validateEntityTagsRequired(request.category(), tags.size()); Post post = Post.builder() .userId(userId) @@ -189,7 +188,6 @@ public PageResponse search(String q, int page, int size) { * 게시글을 수정한다. 작성자 본인만 수정할 수 있다. * category·title·content는 null이면 기존값을 유지하고, entityTags는 null이면 기존 태그를 유지한다. * entityTags가 주어지면 기존 태그를 전체 삭제 후 재삽입한다. - * 수정 후 최종 category가 REVIEW·INFO면 최종 entityTags가 1개 이상이어야 한다. */ @Transactional public void update(Long userId, Long id, PostUpdateRequest request) { @@ -198,12 +196,6 @@ public void update(Long userId, Long id, PostUpdateRequest request) { throw new PostForbiddenException(); } - PostCategory effectiveCategory = request.category() != null ? request.category() : post.getCategory(); - int effectiveTagCount = request.entityTags() != null - ? request.entityTags().size() - : postEntityTagRepository.findByPostId(id).size(); - validateEntityTagsRequired(effectiveCategory, effectiveTagCount); - String content = request.content() != null ? writeContent(request.content()) : null; String contentText = request.content() != null ? TiptapTextExtractor.extract(request.content()) : null; post.update(request.category(), request.title(), content, contentText); @@ -277,13 +269,6 @@ private Object readContent(String content) { } } - private void validateEntityTagsRequired(PostCategory category, int tagCount) { - boolean requiresTags = category == PostCategory.REVIEW || category == PostCategory.INFO; - if (requiresTags && tagCount == 0) { - throw new InvalidInputException(); - } - } - private void saveEntityTags(Long postId, List tags) { tags.forEach(tag -> postEntityTagRepository.save(PostEntityTag.builder() .postId(postId) diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 442b96b..a65f424 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -115,25 +115,39 @@ private User buildUser(Long id, String nickname) { // ------------------------------------------------------------------------- @Test - void should_throw_invalid_input_exception_when_review_category_given_without_entity_tags() { + void should_create_post_when_review_category_given_without_entity_tags() { // given PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), null); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); - // when & then - assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) - .isInstanceOf(InvalidInputException.class) - .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); } @Test - void should_throw_invalid_input_exception_when_info_category_given_without_entity_tags() { + void should_create_post_when_info_category_given_without_entity_tags() { // given PostCreateRequest request = new PostCreateRequest(PostCategory.INFO, "정보 제목", sampleContent(), List.of()); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); - // when & then - assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) - .isInstanceOf(InvalidInputException.class) - .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postEntityTagRepository, never()).save(any(PostEntityTag.class)); } @Test @@ -524,31 +538,17 @@ void should_replace_entity_tags_when_entity_tags_given_in_request() { } @Test - void should_throw_invalid_input_exception_when_effective_category_requires_tags_and_existing_tags_empty() { + void should_update_category_when_review_category_given_without_entity_tags() { // given Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of()); PostUpdateRequest request = new PostUpdateRequest(PostCategory.REVIEW, null, null, null); - // when & then - assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) - .isInstanceOf(InvalidInputException.class) - .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); - assertThat(post.getCategory()).isEqualTo(PostCategory.FREE); - } - - @Test - void should_throw_invalid_input_exception_when_effective_category_requires_tags_and_request_tags_empty() { - // given - Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - PostUpdateRequest request = new PostUpdateRequest(null, null, null, List.of()); + // when + postService.update(AUTHOR_ID, POST_ID, request); - // when & then - assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) - .isInstanceOf(InvalidInputException.class) - .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + // then + assertThat(post.getCategory()).isEqualTo(PostCategory.REVIEW); } // ------------------------------------------------------------------------- From ccb3fa4abe1378610cd16c9f01ec28e39ccd97fc Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:38:59 +0900 Subject: [PATCH 09/54] =?UTF-8?q?[feat]=20=EC=9D=B4=EB=B2=88=20=EC=A3=BC?= =?UTF-8?q?=20=EC=9D=B8=EA=B8=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80/=ED=8A=B8?= =?UTF-8?q?=EB=A0=8C=EB=94=A9=20=ED=83=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커뮤니티 목록 페이지 사이드바 위젯(이번 주 인기글, 지금 많이 언급된 태그)을 위한 백엔드 지원. - GET /api/posts/popular: 최근 N일 게시글을 추천수 내림차순으로 상위 K개 조회 - GET /api/posts/trending-tags: 최근 N일 게시글에 태그된 엔티티를 언급 빈도순으로 상위 K개 조회 (태그 재삽입 이슈를 피하기 위해 post.createdAt 기준으로 필터링) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/controller/PostController.java | 19 ++++ .../Backend/post/dto/TrendingTagResponse.java | 11 +++ .../post/repository/EntityTagCount.java | 6 ++ .../repository/PostEntityTagRepository.java | 18 ++++ .../post/repository/PostRepository.java | 6 ++ .../Backend/post/service/PostService.java | 36 +++++++ .../post/controller/PostControllerTest.java | 41 ++++++++ .../Backend/post/service/PostServiceTest.java | 98 +++++++++++++++++++ 8 files changed, 235 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java index e487f91..782fb39 100644 --- a/src/main/java/com/Coming/Backend/post/controller/PostController.java +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -7,6 +7,7 @@ import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.service.PostService; import io.swagger.v3.oas.annotations.Operation; @@ -30,6 +31,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "Post") @Validated @RestController @@ -65,6 +68,22 @@ public ResponseEntity> getList( return ResponseEntity.ok(postService.getList(category, page, size)); } + @Operation(summary = "이번 주 인기 게시글 조회") + @GetMapping("/popular") + public ResponseEntity> getPopular( + @RequestParam(defaultValue = "7") @Min(1) int days, + @RequestParam(defaultValue = "5") @Min(1) @Max(100) int limit) { + return ResponseEntity.ok(postService.getPopular(days, limit)); + } + + @Operation(summary = "최근 많이 언급된 태그 조회") + @GetMapping("/trending-tags") + public ResponseEntity> getTrendingTags( + @RequestParam(defaultValue = "7") @Min(1) int days, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int limit) { + return ResponseEntity.ok(postService.getTrendingTags(days, limit)); + } + @Operation(summary = "게시글 수정") @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") diff --git a/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java new file mode 100644 index 0000000..fa8b764 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.dto; + +import com.Coming.Backend.post.entity.EntityType; + +public record TrendingTagResponse( + EntityType entityType, + Long entityId, + String title, + Long count +) { +} diff --git a/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java new file mode 100644 index 0000000..1bad34a --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java @@ -0,0 +1,6 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.EntityType; + +public record EntityTagCount(EntityType entityType, Long entityId, Long count) { +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java index 432f88f..90eea81 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java @@ -1,8 +1,12 @@ package com.Coming.Backend.post.repository; import com.Coming.Backend.post.entity.PostEntityTag; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -13,4 +17,18 @@ public interface PostEntityTagRepository extends JpaRepository findByPostIdIn(Collection postIds); void deleteByPostId(Long postId); + + /** + * 태그 자체가 아닌 게시글 작성일(post.createdAt) 기준으로 필터링한다. + * 태그는 게시글 수정 시 삭제 후 재삽입되므로, 태그의 createdAt으로 필터링하면 + * 단순 수정만으로 최근 언급인 것처럼 집계될 수 있다. + */ + @Query(""" + SELECT new com.Coming.Backend.post.repository.EntityTagCount(t.entityType, t.entityId, COUNT(t)) + FROM PostEntityTag t + WHERE t.postId IN (SELECT p.id FROM Post p WHERE p.createdAt >= :since) + GROUP BY t.entityType, t.entityId + ORDER BY COUNT(t) DESC + """) + List findTrendingEntityTags(@Param("since") LocalDateTime since, Pageable pageable); } diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index a7d68a6..b81db27 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -10,11 +10,17 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.List; + public interface PostRepository extends JpaRepository { @Query("SELECT p FROM Post p WHERE (:category IS NULL OR p.category = :category) ORDER BY p.createdAt DESC") Page findPosts(@Param("category") PostCategory category, Pageable pageable); + @Query("SELECT p FROM Post p WHERE p.createdAt >= :since ORDER BY p.recommendCount DESC, p.createdAt DESC") + List findPopularPosts(@Param("since") LocalDateTime since, Pageable pageable); + @Modifying @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") void incrementViewCount(@Param("id") Long id); diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 861fe2b..e138429 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -13,6 +13,7 @@ import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; @@ -22,6 +23,7 @@ import com.Coming.Backend.post.exception.NotRecommendedException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.EntityTagCount; import com.Coming.Backend.post.repository.PostEntityTagRepository; import com.Coming.Backend.post.repository.PostRecommendRepository; import com.Coming.Backend.post.repository.PostRepository; @@ -37,6 +39,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.Locale; import java.util.Map; @@ -173,6 +176,39 @@ public PageResponse getBacklinks(EntityType entityType, Lon return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); } + /** + * 최근 N일 이내 작성된 게시글을 추천수 내림차순으로 상위 K개 조회한다. + */ + public List getPopular(int days, int limit) { + LocalDateTime since = LocalDateTime.now().minusDays(days); + List posts = postRepository.findPopularPosts(since, PageRequest.of(0, limit)); + return toSummaryResponses(posts); + } + + /** + * 최근 N일 이내 작성된 게시글에 태그된 엔티티를 언급 빈도 내림차순으로 상위 K개 조회한다. + */ + public List getTrendingTags(int days, int limit) { + LocalDateTime since = LocalDateTime.now().minusDays(days); + List counts = postEntityTagRepository.findTrendingEntityTags(since, PageRequest.of(0, limit)); + if (counts.isEmpty()) { + return List.of(); + } + + List keys = counts.stream() + .map(count -> new EntityLookupService.EntityKey(count.entityType(), count.entityId())) + .toList(); + Map cards = entityLookupService.findCards(keys); + + return counts.stream() + .filter(count -> cards.containsKey(new EntityLookupService.EntityKey(count.entityType(), count.entityId()))) + .map(count -> { + EntityCardResponse card = cards.get(new EntityLookupService.EntityKey(count.entityType(), count.entityId())); + return new TrendingTagResponse(count.entityType(), count.entityId(), card.title(), count.count()); + }) + .toList(); + } + /** * 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정. */ diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index 2601929..093f5d2 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -23,6 +23,8 @@ import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; +import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.exception.AlreadyRecommendedException; import com.Coming.Backend.post.exception.NotRecommendedException; @@ -180,6 +182,45 @@ void should_return_200_with_page_response_when_getting_list() throws Exception { .andExpect(jsonPath("$.totalElements").value(1)); } + // ------------------------------------------------------------------------- + // GET /api/posts/popular + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_popular_posts_when_default_params_given() throws Exception { + // given + PostSummaryResponse summary = new PostSummaryResponse( + POST_ID, "IU", PostCategory.FREE, "인기 게시글", List.of(), 10L, 0L, LocalDateTime.now()); + given(postService.getPopular(eq(7), eq(5))).willReturn(List.of(summary)); + + // when & then + mockMvc.perform(get("/api/posts/popular").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].id").value(POST_ID)) + .andExpect(jsonPath("$[0].title").value("인기 게시글")); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/trending-tags + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_trending_tags_when_default_params_given() throws Exception { + // given + TrendingTagResponse tag = new TrendingTagResponse(EntityType.ARTIST, 1L, "IU", 5L); + given(postService.getTrendingTags(eq(7), eq(10))).willReturn(List.of(tag)); + + // when & then + mockMvc.perform(get("/api/posts/trending-tags").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].entityType").value("ARTIST")) + .andExpect(jsonPath("$[0].entityId").value(1)) + .andExpect(jsonPath("$[0].title").value("IU")) + .andExpect(jsonPath("$[0].count").value(5)); + } + // ------------------------------------------------------------------------- // PATCH /api/posts/{id} // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index a65f424..64e5f4b 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -23,6 +23,7 @@ import com.Coming.Backend.post.dto.PostSummaryResponse; import com.Coming.Backend.post.dto.PostUpdateRequest; import com.Coming.Backend.post.dto.RecommendCountResponse; +import com.Coming.Backend.post.dto.TrendingTagResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; @@ -32,9 +33,11 @@ import com.Coming.Backend.post.exception.NotRecommendedException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.EntityTagCount; import com.Coming.Backend.post.repository.PostEntityTagRepository; import com.Coming.Backend.post.repository.PostRecommendRepository; import com.Coming.Backend.post.repository.PostRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -406,6 +409,101 @@ void should_return_empty_content_when_no_backlinks_found() { assertThat(response.totalElements()).isZero(); } + // ------------------------------------------------------------------------- + // getPopular + // ------------------------------------------------------------------------- + + @Test + void should_return_summary_responses_when_popular_posts_found() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "인기 게시글", 10L); + Pageable pageable = PageRequest.of(0, 5); + given(postRepository.findPopularPosts(any(LocalDateTime.class), eq(pageable))).willReturn(List.of(post)); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + List response = postService.getPopular(7, 5); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).title()).isEqualTo("인기 게시글"); + } + + @Test + void should_return_empty_list_when_no_popular_posts_found() { + // given + Pageable pageable = PageRequest.of(0, 5); + given(postRepository.findPopularPosts(any(LocalDateTime.class), eq(pageable))).willReturn(List.of()); + + // when + List response = postService.getPopular(7, 5); + + // then + assertThat(response).isEmpty(); + verify(postEntityTagRepository, never()).findByPostIdIn(any()); + } + + // ------------------------------------------------------------------------- + // getTrendingTags + // ------------------------------------------------------------------------- + + @Test + void should_return_trending_tags_when_entity_tags_found() { + // given + Pageable pageable = PageRequest.of(0, 10); + EntityTagCount count = new EntityTagCount(EntityType.ARTIST, 1L, 3L); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))).willReturn(List.of(count)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + + // when + List response = postService.getTrendingTags(7, 10); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).entityType()).isEqualTo(EntityType.ARTIST); + assertThat(response.get(0).entityId()).isEqualTo(1L); + assertThat(response.get(0).title()).isEqualTo("IU"); + assertThat(response.get(0).count()).isEqualTo(3L); + } + + @Test + void should_return_empty_list_when_no_trending_tags_found() { + // given + Pageable pageable = PageRequest.of(0, 10); + given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))).willReturn(List.of()); + + // when + List response = postService.getTrendingTags(7, 10); + + // then + assertThat(response).isEmpty(); + verify(entityLookupService, never()).findCards(any()); + } + + @Test + void should_filter_out_trending_tag_when_entity_reference_is_deleted() { + // given + Pageable pageable = PageRequest.of(0, 10); + EntityTagCount aliveCount = new EntityTagCount(EntityType.ARTIST, 1L, 3L); + EntityTagCount deletedCount = new EntityTagCount(EntityType.CONCERT, 2L, 2L); + EntityLookupService.EntityKey aliveKey = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); + EntityLookupService.EntityKey deletedKey = new EntityLookupService.EntityKey(EntityType.CONCERT, 2L); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))) + .willReturn(List.of(aliveCount, deletedCount)); + given(entityLookupService.findCards(List.of(aliveKey, deletedKey))).willReturn(Map.of(aliveKey, card)); + + // when + List response = postService.getTrendingTags(7, 10); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).entityId()).isEqualTo(1L); + } + // ------------------------------------------------------------------------- // search // ------------------------------------------------------------------------- From 9c743e6bf1b0bbf53e08e6f4438cb294dc62033b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 16:59:36 +0900 Subject: [PATCH 10/54] =?UTF-8?q?[chore]=20develop=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=8F=84=EC=9E=85=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?CI=20=EB=8C=80=EC=83=81=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=20=ED=9D=90=EB=A6=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업 브랜치 → develop → main 순으로 병합하는 흐름으로 전환하면서 CI가 develop으로의 PR도 검증하도록 대상 브랜치를 추가하고, README의 CI/CD 설명을 새 흐름에 맞게 갱신 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .github/workflows/ci.yml | 2 +- README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 956a7e9..a40787f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [main] + branches: [main, develop] jobs: test: diff --git a/README.md b/README.md index ff95b64..f2d1299 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,9 @@ open -a Docker && docker start redis # Docker 데몬이 꺼져 있으면 먼 배포 아키텍처

-- **CI** (`ci.yml`): PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증 -- **CD** (`cd.yml`): `main` 브랜치 push 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행 +- 작업 브랜치 → `develop` PR/병합 → (여러 작업 누적 후) `develop` → `main` PR/병합 순으로 운영 서버에 배포됩니다. +- **CI** (`ci.yml`): `main`/`develop`으로의 PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증 +- **CD** (`cd.yml`): `main` 브랜치 push(= `develop` → `main` 병합) 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행 - Nginx가 `be-blue`(:8080)/`be-green`(:8081) 중 활성 슬롯으로만 트래픽을 전달하고, Redis는 두 슬롯이 공유합니다. Data Pipeline(`data`)도 같은 Docker Compose에 포함되어 별도 인스턴스 없이 함께 배포됩니다. - 배포 시 standby 슬롯에 새 이미지를 pull → `/actuator/health` 체크 통과 → Nginx upstream 전환 → 이전 슬롯 정지 순으로 무중단 배포합니다. - DB는 별도 Lightsail 인스턴스(Managed PostgreSQL)로 분리되어 두 슬롯이 공통으로 바라보고, 4xx/5xx 에러·공연 데이터 수집 결과·문의 접수는 각각 Discord Webhook으로 알림됩니다. From effc6952c83b32bae259f690e81bed8e3b238181 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 17:24:47 +0900 Subject: [PATCH 11/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EA=B8=80=EC=9E=90=EC=88=98=20=EC=83=81?= =?UTF-8?q?=ED=95=9C(10000=EC=9E=90)=20=EC=84=9C=EB=B2=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiptap content에서 추출한 순수 텍스트(contentText) 기준으로 길이를 검증해 비정상적으로 큰 payload를 DB에 저장하지 못하도록 막는다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/exception/ErrorCode.java | 1 + .../PostContentTooLongException.java | 11 +++++++++++ .../Backend/post/service/PostService.java | 19 +++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 599619c..5d8dad7 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -53,6 +53,7 @@ public enum ErrorCode { POST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), ALREADY_RECOMMENDED(HttpStatus.CONFLICT, "이미 추천한 게시글입니다."), NOT_RECOMMENDED(HttpStatus.BAD_REQUEST, "추천하지 않은 게시글입니다."), + POST_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "본문은 10000자를 초과할 수 없습니다."), // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), diff --git a/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java new file mode 100644 index 0000000..717ce5d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PostContentTooLongException extends BusinessException { + + public PostContentTooLongException() { + super(ErrorCode.POST_CONTENT_TOO_LONG); + } +} diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index e138429..ddeba3a 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -21,6 +21,7 @@ import com.Coming.Backend.post.entity.PostRecommend; import com.Coming.Backend.post.exception.AlreadyRecommendedException; import com.Coming.Backend.post.exception.NotRecommendedException; +import com.Coming.Backend.post.exception.PostContentTooLongException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; import com.Coming.Backend.post.repository.EntityTagCount; @@ -62,6 +63,8 @@ public class PostService { */ private final ObjectMapper contentObjectMapper = new ObjectMapper(); + private static final int MAX_CONTENT_TEXT_LENGTH = 10000; + /** * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 @@ -71,12 +74,15 @@ public class PostService { public PostCreateResponse create(Long userId, PostCreateRequest request) { List tags = request.entityTags() == null ? List.of() : request.entityTags(); + String contentText = TiptapTextExtractor.extract(request.content()); + validateContentTextLength(contentText); + Post post = Post.builder() .userId(userId) .category(request.category()) .title(request.title()) .content(writeContent(request.content())) - .contentText(TiptapTextExtractor.extract(request.content())) + .contentText(contentText) .recommendCount(0L) .viewCount(0L) .build(); @@ -232,8 +238,11 @@ public void update(Long userId, Long id, PostUpdateRequest request) { throw new PostForbiddenException(); } - String content = request.content() != null ? writeContent(request.content()) : null; String contentText = request.content() != null ? TiptapTextExtractor.extract(request.content()) : null; + if (contentText != null) { + validateContentTextLength(contentText); + } + String content = request.content() != null ? writeContent(request.content()) : null; post.update(request.category(), request.title(), content, contentText); if (request.entityTags() != null) { @@ -289,6 +298,12 @@ public RecommendCountResponse unrecommend(Long userId, Long id) { return new RecommendCountResponse(post.getRecommendCount() - 1); } + private void validateContentTextLength(String contentText) { + if (contentText.length() > MAX_CONTENT_TEXT_LENGTH) { + throw new PostContentTooLongException(); + } + } + private String writeContent(Object content) { try { return contentObjectMapper.writeValueAsString(content); From 0e04469f68674473fc8f608f5080f43c38a40e18 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 17:38:55 +0900 Subject: [PATCH 12/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=B5=9C=EB=8C=80=2010=EA=B0=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EB=B0=8F=20=EB=B3=B8=EB=AC=B8=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EC=88=98=C2=B7=ED=83=9C=EA=B7=B8=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entityTags에 @Size(max=10) 검증을 추가하고, 이전에 추가한 본문 10000자 상한 검증과 함께 경계값·초과값 테스트를 작성한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/dto/PostCreateRequest.java | 1 + .../Backend/post/dto/PostUpdateRequest.java | 1 + .../post/controller/PostControllerTest.java | 53 +++++++++++++ .../Backend/post/service/PostServiceTest.java | 75 +++++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java index 4b7e946..1b6205b 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java @@ -19,6 +19,7 @@ public record PostCreateRequest( @NotNull Object content, + @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다") List<@Valid EntityTagRequest> entityTags ) { } diff --git a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java index 200b756..d02aaf9 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java @@ -14,6 +14,7 @@ public record PostUpdateRequest( Object content, + @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다") List<@Valid EntityTagRequest> entityTags ) { } diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index 093f5d2..ffb1756 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -1,11 +1,13 @@ package com.Coming.Backend.post.controller; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; @@ -17,6 +19,7 @@ import com.Coming.Backend.common.exception.GlobalExceptionHandler; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.EntityTagRequest; import com.Coming.Backend.post.dto.PostCreateRequest; import com.Coming.Backend.post.dto.PostCreateResponse; import com.Coming.Backend.post.dto.PostDetailResponse; @@ -35,6 +38,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -95,6 +99,12 @@ private Map sampleContent() { return Map.of("type", "doc", "content", List.of()); } + private List entityTags(int count) { + return IntStream.rangeClosed(1, count) + .mapToObj(i -> new EntityTagRequest(EntityType.ARTIST, (long) i)) + .toList(); + } + // ------------------------------------------------------------------------- // POST /api/posts // ------------------------------------------------------------------------- @@ -128,6 +138,35 @@ void should_return_400_when_service_throws_invalid_input_exception_on_create() t .andExpect(jsonPath("$.message").exists()); } + @Test + void should_return_400_when_create_request_has_more_than_10_entity_tags() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), entityTags(11)); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").value(containsString("태그는 10개를 초과할 수 없습니다"))); + } + + @Test + void should_return_201_when_create_request_has_exactly_10_entity_tags() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "자유 제목", sampleContent(), entityTags(10)); + given(postService.create(eq(USER_ID), any(PostCreateRequest.class))).willReturn(new PostCreateResponse(POST_ID)); + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(POST_ID)); + verify(postService).create(eq(USER_ID), any(PostCreateRequest.class)); + } + // ------------------------------------------------------------------------- // GET /api/posts/{id} // ------------------------------------------------------------------------- @@ -254,6 +293,20 @@ void should_return_403_when_service_throws_forbidden_exception_on_update() throw .andExpect(jsonPath("$.message").exists()); } + @Test + void should_return_400_when_update_request_has_more_than_10_entity_tags() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, null, null, entityTags(11)); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").value(containsString("태그는 10개를 초과할 수 없습니다"))); + } + // ------------------------------------------------------------------------- // DELETE /api/posts/{id} // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 64e5f4b..b2ab280 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -31,6 +31,7 @@ import com.Coming.Backend.post.entity.PostRecommend; import com.Coming.Backend.post.exception.AlreadyRecommendedException; import com.Coming.Backend.post.exception.NotRecommendedException; +import com.Coming.Backend.post.exception.PostContentTooLongException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; import com.Coming.Backend.post.repository.EntityTagCount; @@ -96,6 +97,20 @@ private Map sampleContent() { ); } + /** + * "a"를 length만큼 반복한 텍스트 노드 하나를 포함한 Tiptap 문서. contentText 길이를 정확히 통제하기 위해 사용한다. + */ + private Map contentWithLength(int length) { + return Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "a".repeat(length)) + )) + ) + ); + } + private Post buildPost(Long id, Long userId, PostCategory category, String title, long viewCount) { return Post.builder() .id(id) @@ -197,6 +212,36 @@ void should_save_entity_tags_when_review_category_given_with_entity_tags() { verify(postEntityTagRepository, times(2)).save(any(PostEntityTag.class)); } + @Test + void should_create_post_when_content_text_length_is_exactly_max_length() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "제목", contentWithLength(10000), null); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + PostCreateResponse response = postService.create(AUTHOR_ID, request); + + // then + assertThat(response.id()).isEqualTo(POST_ID); + verify(postRepository).save(any(Post.class)); + } + + @Test + void should_throw_content_too_long_exception_when_content_text_length_exceeds_max_length_on_create() { + // given + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "제목", contentWithLength(10001), null); + + // when & then + assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) + .isInstanceOf(PostContentTooLongException.class) + .hasMessage(ErrorCode.POST_CONTENT_TOO_LONG.getMessage()); + verify(postRepository, never()).save(any(Post.class)); + } + // ------------------------------------------------------------------------- // getDetail // ------------------------------------------------------------------------- @@ -649,6 +694,36 @@ void should_update_category_when_review_category_given_without_entity_tags() { assertThat(post.getCategory()).isEqualTo(PostCategory.REVIEW); } + @Test + void should_update_content_when_content_text_length_is_exactly_max_length() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, null, contentWithLength(10000), null); + + // when + postService.update(AUTHOR_ID, POST_ID, request); + + // then + assertThat(post.getContentText()).hasSize(10000); + } + + @Test + void should_throw_content_too_long_exception_and_keep_existing_fields_when_content_text_length_exceeds_max_length_on_update() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "기존 제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + PostUpdateRequest request = new PostUpdateRequest(null, "새 제목", contentWithLength(10001), null); + + // when & then + assertThatThrownBy(() -> postService.update(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(PostContentTooLongException.class) + .hasMessage(ErrorCode.POST_CONTENT_TOO_LONG.getMessage()); + assertThat(post.getTitle()).isEqualTo("기존 제목"); + assertThat(post.getContent()).isEqualTo("{\"type\":\"doc\"}"); + assertThat(post.getContentText()).isEqualTo("기존 텍스트"); + } + // ------------------------------------------------------------------------- // delete // ------------------------------------------------------------------------- From 094ed723e50355aa2d28a7f52bbd0d1b896ce5b0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:29:32 +0900 Subject: [PATCH 13/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST-07 게시글 댓글 기능을 위한 comment·comment_like 테이블과 post.comment_count 컬럼을 추가한다. 답글 삭제는 하드 삭제 대신 is_deleted 플래그를 통한 소프트 삭제로 처리한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../migration/V31__create_comment_tables.sql | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/resources/db/migration/V31__create_comment_tables.sql diff --git a/src/main/resources/db/migration/V31__create_comment_tables.sql b/src/main/resources/db/migration/V31__create_comment_tables.sql new file mode 100644 index 0000000..7771905 --- /dev/null +++ b/src/main/resources/db/migration/V31__create_comment_tables.sql @@ -0,0 +1,23 @@ +CREATE TABLE comment ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL, + user_id bigint NOT NULL, + parent_comment_id bigint, + content text NOT NULL, + like_count bigint NOT NULL DEFAULT 0, + is_deleted boolean NOT NULL DEFAULT false, + created_at timestamp +); + +CREATE INDEX idx_comment_post_id ON comment (post_id); +CREATE INDEX idx_comment_parent_comment_id ON comment (parent_comment_id); + +CREATE TABLE comment_like ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + comment_id bigint NOT NULL, + created_at timestamp, + UNIQUE (user_id, comment_id) +); + +ALTER TABLE post ADD COLUMN comment_count bigint NOT NULL DEFAULT 0; From f377295a540f141e5258da390d5206f07b603efa Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:30:24 +0900 Subject: [PATCH 14/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=C2=B7?= =?UTF-8?q?=EB=8B=B5=EA=B8=80=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20?= =?UTF-8?q?Post.commentCount=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment·CommentLike 엔티티를 추가하고, 답글 달린 최상위 댓글 삭제 시 하드 삭제 대신 사용할 softDelete() 도메인 메서드를 둔다. Post에는 commentCount 필드를 추가한다(답글 포함 전체 댓글 수, 생성 시에만 증가하고 소프트 삭제로는 감소하지 않음). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Coming/Backend/post/entity/Comment.java | 57 +++++++++++++++++++ .../Backend/post/entity/CommentLike.java | 33 +++++++++++ .../com/Coming/Backend/post/entity/Post.java | 3 + .../Backend/post/service/PostService.java | 1 + .../post/repository/PostRepositoryTest.java | 1 + 5 files changed, 95 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/entity/Comment.java create mode 100644 src/main/java/com/Coming/Backend/post/entity/CommentLike.java diff --git a/src/main/java/com/Coming/Backend/post/entity/Comment.java b/src/main/java/com/Coming/Backend/post/entity/Comment.java new file mode 100644 index 0000000..3cf0cd5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/Comment.java @@ -0,0 +1,57 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Comment extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "parent_comment_id") + private Long parentCommentId; + + @Column(name = "content", nullable = false, columnDefinition = "text") + private String content; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted; + + public boolean isAuthoredBy(Long userId) { + return userId != null && this.userId.equals(userId); + } + + public boolean isReply() { + return parentCommentId != null; + } + + public void softDelete() { + this.deleted = true; + } +} diff --git a/src/main/java/com/Coming/Backend/post/entity/CommentLike.java b/src/main/java/com/Coming/Backend/post/entity/CommentLike.java new file mode 100644 index 0000000..acdd70e --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/entity/CommentLike.java @@ -0,0 +1,33 @@ +package com.Coming.Backend.post.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment_like") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class CommentLike extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "comment_id", nullable = false) + private Long commentId; +} diff --git a/src/main/java/com/Coming/Backend/post/entity/Post.java b/src/main/java/com/Coming/Backend/post/entity/Post.java index 177cfbb..9c42023 100644 --- a/src/main/java/com/Coming/Backend/post/entity/Post.java +++ b/src/main/java/com/Coming/Backend/post/entity/Post.java @@ -52,6 +52,9 @@ public class Post extends BaseTimeEntity { @Column(name = "view_count", nullable = false) private Long viewCount; + @Column(name = "comment_count", nullable = false) + private Long commentCount; + public boolean isAuthoredBy(Long userId) { return userId != null && this.userId.equals(userId); } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index ddeba3a..6a3296a 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -85,6 +85,7 @@ public PostCreateResponse create(Long userId, PostCreateRequest request) { .contentText(contentText) .recommendCount(0L) .viewCount(0L) + .commentCount(0L) .build(); postRepository.save(post); saveEntityTags(post.getId(), tags); diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 18efd6e..c37b2e1 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -48,6 +48,7 @@ private Post buildPost(String title, String contentText) { .contentText(contentText) .recommendCount(0L) .viewCount(0L) + .commentCount(0L) .build(); } From f02db3c5564c377e081c112a181f424fb72ae369 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:30:53 +0900 Subject: [PATCH 15/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=20Repository?= =?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 CommentRepository(최상위 댓글 페이지네이션, 답글 배치 조회, 좋아요 수 증감), CommentLikeRepository(좋아요 여부·배치 조회)를 추가하고 PostRepository에 incrementCommentCount를 추가한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../repository/CommentLikeRepository.java | 19 +++++++++++++ .../post/repository/CommentRepository.java | 27 +++++++++++++++++++ .../post/repository/PostRepository.java | 4 +++ 3 files changed, 50 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java create mode 100644 src/main/java/com/Coming/Backend/post/repository/CommentRepository.java diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java new file mode 100644 index 0000000..ffbade6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java @@ -0,0 +1,19 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.CommentLike; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentLikeRepository extends JpaRepository { + + boolean existsByUserIdAndCommentId(Long userId, Long commentId); + + Optional findByUserIdAndCommentId(Long userId, Long commentId); + + @Query("SELECT cl.commentId FROM CommentLike cl WHERE cl.userId = :userId AND cl.commentId IN :commentIds") + List findLikedCommentIds(@Param("userId") Long userId, @Param("commentIds") Collection commentIds); +} diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java new file mode 100644 index 0000000..a752344 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java @@ -0,0 +1,27 @@ +package com.Coming.Backend.post.repository; + +import com.Coming.Backend.post.entity.Comment; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c WHERE c.postId = :postId AND c.parentCommentId IS NULL ORDER BY c.createdAt ASC") + Page findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable); + + List findByParentCommentIdInOrderByCreatedAtAsc(Collection parentCommentIds); + + @Modifying + @Query("UPDATE Comment c SET c.likeCount = c.likeCount + 1 WHERE c.id = :id") + void incrementLikeCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Comment c SET c.likeCount = c.likeCount - 1 WHERE c.id = :id") + void decrementLikeCount(@Param("id") Long id); +} diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index b81db27..ef3b550 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -33,6 +33,10 @@ public interface PostRepository extends JpaRepository { @Query("UPDATE Post p SET p.recommendCount = p.recommendCount - 1 WHERE p.id = :id") void decrementRecommendCount(@Param("id") Long id); + @Modifying + @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id") + void incrementCommentCount(@Param("id") Long id); + @Query("SELECT p FROM Post p WHERE p.id IN (SELECT t.postId FROM PostEntityTag t WHERE t.entityType = :entityType AND t.entityId = :entityId)") Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable); From 1da7e06d122db6181c13ed9a0168fc293759cb42 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:32:02 +0900 Subject: [PATCH 16/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=20DTO=C2=B7?= =?UTF-8?q?=EC=98=88=EC=99=B8=C2=B7ErrorCode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommentCreateRequest/Response, CommentResponse(답글 중첩), CommentLikeCountResponse와 CommentNotFoundException· CommentForbiddenException·InvalidReplyDepthException· AlreadyLikedException·NotLikedException을 추가한다. PostDetailResponse에 commentCount 필드를 추가한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/exception/ErrorCode.java | 6 ++++++ .../Backend/post/dto/CommentCreateRequest.java | 11 +++++++++++ .../Backend/post/dto/CommentCreateResponse.java | 4 ++++ .../post/dto/CommentLikeCountResponse.java | 4 ++++ .../Coming/Backend/post/dto/CommentResponse.java | 16 ++++++++++++++++ .../Backend/post/dto/PostDetailResponse.java | 1 + .../post/exception/AlreadyLikedException.java | 11 +++++++++++ .../exception/CommentForbiddenException.java | 11 +++++++++++ .../post/exception/CommentNotFoundException.java | 11 +++++++++++ .../exception/InvalidReplyDepthException.java | 11 +++++++++++ .../post/exception/NotLikedException.java | 11 +++++++++++ .../Coming/Backend/post/service/PostService.java | 1 + .../post/controller/PostControllerTest.java | 2 +- .../Backend/post/service/PostServiceTest.java | 1 + 14 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/dto/CommentResponse.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java create mode 100644 src/main/java/com/Coming/Backend/post/exception/NotLikedException.java diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 5d8dad7..ae70ceb 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -55,6 +55,12 @@ public enum ErrorCode { NOT_RECOMMENDED(HttpStatus.BAD_REQUEST, "추천하지 않은 게시글입니다."), POST_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "본문은 10000자를 초과할 수 없습니다."), + // Comment + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + INVALID_REPLY_DEPTH(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."), + ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요한 댓글입니다."), + NOT_LIKED(HttpStatus.BAD_REQUEST, "좋아요하지 않은 댓글입니다."), + // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java new file mode 100644 index 0000000..c8afc7e --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentCreateRequest( + @NotBlank + String content, + + Long parentCommentId +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java new file mode 100644 index 0000000..d993711 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record CommentCreateResponse(Long id) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java new file mode 100644 index 0000000..04a9e84 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.post.dto; + +public record CommentLikeCountResponse(Long likeCount) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java new file mode 100644 index 0000000..6ba29a3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java @@ -0,0 +1,16 @@ +package com.Coming.Backend.post.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record CommentResponse( + Long id, + String authorNickname, + boolean isAuthor, + String content, + Long likeCount, + Boolean isLiked, + LocalDateTime createdAt, + List replies +) { +} diff --git a/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java index 7b06583..50e4068 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java @@ -14,6 +14,7 @@ public record PostDetailResponse( List entityTags, Long recommendCount, Long viewCount, + Long commentCount, Boolean isRecommended, boolean isAuthor, LocalDateTime createdAt, diff --git a/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java new file mode 100644 index 0000000..21f680d --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class AlreadyLikedException extends BusinessException { + + public AlreadyLikedException() { + super(ErrorCode.ALREADY_LIKED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java new file mode 100644 index 0000000..d32b1ec --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class CommentForbiddenException extends BusinessException { + + public CommentForbiddenException() { + super(ErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java new file mode 100644 index 0000000..2b7de00 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class CommentNotFoundException extends BusinessException { + + public CommentNotFoundException() { + super(ErrorCode.COMMENT_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java b/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java new file mode 100644 index 0000000..958b703 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class InvalidReplyDepthException extends BusinessException { + + public InvalidReplyDepthException() { + super(ErrorCode.INVALID_REPLY_DEPTH); + } +} diff --git a/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java b/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java new file mode 100644 index 0000000..8c0c6e1 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.post.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class NotLikedException extends BusinessException { + + public NotLikedException() { + super(ErrorCode.NOT_LIKED); + } +} diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 6a3296a..c80885b 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -120,6 +120,7 @@ public PostDetailResponse getDetail(Long id, Long userId) { entityTags, post.getRecommendCount(), post.getViewCount() + 1, + post.getCommentCount(), isRecommended, post.isAuthoredBy(userId), post.getCreatedAt(), diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index ffb1756..e2295db 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -176,7 +176,7 @@ void should_return_200_with_post_detail_when_post_exists() throws Exception { // given PostDetailResponse detail = new PostDetailResponse( POST_ID, "IU", PostCategory.FREE, "제목", sampleContent(), - List.of(), 0L, 1L, null, true, LocalDateTime.now(), LocalDateTime.now() + List.of(), 0L, 1L, 0L, null, true, LocalDateTime.now(), LocalDateTime.now() ); given(postService.getDetail(eq(POST_ID), eq(USER_ID))).willReturn(detail); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index b2ab280..64f563b 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -121,6 +121,7 @@ private Post buildPost(Long id, Long userId, PostCategory category, String title .contentText("기존 텍스트") .recommendCount(0L) .viewCount(viewCount) + .commentCount(0L) .build(); } From 97e3c84ba84857b836596f0545b2b9034c2e256b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:36:29 +0900 Subject: [PATCH 17/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=20Service=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommentService(getComments/create/delete/like/unlike)를 구현한다. 답글은 최상위 댓글 조회 시 배치 조회해 중첩하고, 소프트 삭제된 댓글은 응답 매핑 단계에서 content·authorNickname 등을 치환한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/service/CommentService.java | 198 ++++++++ .../post/service/CommentServiceTest.java | 427 ++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/service/CommentService.java create mode 100644 src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java new file mode 100644 index 0000000..e7615d4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -0,0 +1,198 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.entity.Comment; +import com.Coming.Backend.post.entity.CommentLike; +import com.Coming.Backend.post.exception.AlreadyLikedException; +import com.Coming.Backend.post.exception.CommentForbiddenException; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.InvalidReplyDepthException; +import com.Coming.Backend.post.exception.NotLikedException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private static final String DELETED_CONTENT_PLACEHOLDER = "삭제된 댓글입니다"; + + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + /** + * 게시글의 댓글·답글 목록을 조회한다. 최상위 댓글만 페이지네이션 대상이며, 답글은 각 최상위 댓글에 전체 포함된다. + * + * @param userId 인증 사용자 ID. null이면 isLiked는 null, isAuthor는 false로 반환된다. + */ + public PageResponse getComments(Long postId, Long userId, int page, int size) { + postRepository.findById(postId).orElseThrow(PostNotFoundException::new); + + Pageable pageable = PageRequest.of(page, size); + Page topLevelPage = commentRepository.findTopLevelByPostId(postId, pageable); + List topLevelComments = topLevelPage.getContent(); + + List topLevelIds = topLevelComments.stream().map(Comment::getId).toList(); + Map> repliesByParentId = commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(topLevelIds) + .stream() + .collect(Collectors.groupingBy(Comment::getParentCommentId)); + + List allComments = Stream.concat( + topLevelComments.stream(), + repliesByParentId.values().stream().flatMap(List::stream) + ).toList(); + Map nicknameByUserId = findNicknames(allComments); + Set likedCommentIds = findLikedCommentIds(userId, allComments); + + List content = topLevelComments.stream() + .map(comment -> toResponse(comment, repliesByParentId.getOrDefault(comment.getId(), List.of()), + userId, nicknameByUserId, likedCommentIds)) + .toList(); + + return new PageResponse<>(content, topLevelPage.getNumber(), topLevelPage.getSize(), + topLevelPage.getTotalElements(), topLevelPage.getTotalPages()); + } + + /** + * 댓글 또는 답글을 작성한다. parentCommentId가 주어지면 답글로 작성하며, 상위 댓글이 이미 답글이면 + * InvalidReplyDepthException을 던진다(2단계 이상 중첩 금지). + */ + @Transactional + public CommentCreateResponse create(Long userId, Long postId, CommentCreateRequest request) { + postRepository.findById(postId).orElseThrow(PostNotFoundException::new); + + Long parentCommentId = request.parentCommentId(); + if (parentCommentId != null) { + Comment parent = commentRepository.findById(parentCommentId) + .filter(comment -> comment.getPostId().equals(postId)) + .orElseThrow(CommentNotFoundException::new); + if (parent.isReply()) { + throw new InvalidReplyDepthException(); + } + } + + Comment comment = Comment.builder() + .postId(postId) + .userId(userId) + .parentCommentId(parentCommentId) + .content(request.content()) + .likeCount(0L) + .deleted(false) + .build(); + commentRepository.save(comment); + postRepository.incrementCommentCount(postId); + + return new CommentCreateResponse(comment.getId()); + } + + /** + * 댓글 또는 답글을 삭제한다. 작성자 본인만 삭제할 수 있다. + * 하드 삭제 대신 소프트 삭제로 처리해 답글이 달린 댓글이 삭제돼도 답글은 그대로 유지된다. + */ + @Transactional + public void delete(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (!comment.isAuthoredBy(userId)) { + throw new CommentForbiddenException(); + } + comment.softDelete(); + } + + /** + * 댓글에 좋아요를 남긴다. 이미 좋아요한 댓글이면 AlreadyLikedException을 던진다. + */ + @Transactional + public CommentLikeCountResponse like(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) { + throw new AlreadyLikedException(); + } + try { + commentLikeRepository.save(CommentLike.builder() + .userId(userId) + .commentId(commentId) + .build()); + } catch (DataIntegrityViolationException e) { + throw new AlreadyLikedException(); + } + commentRepository.incrementLikeCount(commentId); + return new CommentLikeCountResponse(comment.getLikeCount() + 1); + } + + /** + * 댓글 좋아요를 취소한다. 좋아요한 적 없으면 NotLikedException을 던진다. + */ + @Transactional + public CommentLikeCountResponse unlike(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + CommentLike like = commentLikeRepository.findByUserIdAndCommentId(userId, commentId) + .orElseThrow(NotLikedException::new); + commentLikeRepository.delete(like); + commentRepository.decrementLikeCount(commentId); + return new CommentLikeCountResponse(comment.getLikeCount() - 1); + } + + private CommentResponse toResponse(Comment comment, List replies, Long userId, + Map nicknameByUserId, Set likedCommentIds) { + List replyResponses = replies.stream() + .map(reply -> toResponse(reply, List.of(), userId, nicknameByUserId, likedCommentIds)) + .toList(); + + if (comment.isDeleted()) { + return new CommentResponse(comment.getId(), null, false, DELETED_CONTENT_PLACEHOLDER, 0L, null, + comment.getCreatedAt(), replyResponses); + } + return new CommentResponse( + comment.getId(), + nicknameByUserId.get(comment.getUserId()), + comment.isAuthoredBy(userId), + comment.getContent(), + comment.getLikeCount(), + userId == null ? null : likedCommentIds.contains(comment.getId()), + comment.getCreatedAt(), + replyResponses + ); + } + + private Map findNicknames(List comments) { + if (comments.isEmpty()) { + return Map.of(); + } + Set userIds = comments.stream().map(Comment::getUserId).collect(Collectors.toSet()); + return userRepository.findAllByIdIn(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getNickname)); + } + + private Set findLikedCommentIds(Long userId, List comments) { + if (userId == null || comments.isEmpty()) { + return Set.of(); + } + List commentIds = comments.stream().map(Comment::getId).toList(); + return new HashSet<>(commentLikeRepository.findLikedCommentIds(userId, commentIds)); + } +} diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java new file mode 100644 index 0000000..0b26c48 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -0,0 +1,427 @@ +package com.Coming.Backend.post.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.entity.Comment; +import com.Coming.Backend.post.entity.CommentLike; +import com.Coming.Backend.post.entity.Post; +import com.Coming.Backend.post.exception.AlreadyLikedException; +import com.Coming.Backend.post.exception.CommentForbiddenException; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.InvalidReplyDepthException; +import com.Coming.Backend.post.exception.NotLikedException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +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 CommentServiceTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentLikeRepository commentLikeRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private UserRepository userRepository; + + private static final Long POST_ID = 100L; + private static final Long OTHER_POST_ID = 200L; + private static final Long AUTHOR_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long VIEWER_ID = 3L; + private static final Long COMMENT_ID = 10L; + private static final Long REPLY_ID = 11L; + private static final Long PARENT_ID = 20L; + + private Comment buildComment(Long id, Long postId, Long userId, Long parentCommentId, String content, + long likeCount, boolean deleted) { + return Comment.builder() + .id(id) + .postId(postId) + .userId(userId) + .parentCommentId(parentCommentId) + .content(content) + .likeCount(likeCount) + .deleted(deleted) + .build(); + } + + private User buildUser(Long id, String nickname) { + return User.builder().id(id).nickname(nickname).build(); + } + + // ------------------------------------------------------------------------- + // getComments + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_post_does_not_exist_on_get_comments() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.getComments(POST_ID, null, 0, 20)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_return_top_level_comments_with_nested_replies_when_comments_exist() { + // given + Post post = Post.builder().id(POST_ID).build(); + Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "탑레벨 댓글", 2L, false); + Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + .willReturn(List.of(reply)); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) + .willReturn(List.of(buildUser(AUTHOR_ID, "IU"), buildUser(OTHER_USER_ID, "뷔"))); + + // when + PageResponse response = commentService.getComments(POST_ID, null, 0, 20); + + // then + assertThat(response.content()).hasSize(1); + CommentResponse topResponse = response.content().get(0); + assertThat(topResponse.id()).isEqualTo(COMMENT_ID); + assertThat(topResponse.replies()).hasSize(1); + assertThat(topResponse.replies().get(0).id()).isEqualTo(REPLY_ID); + assertThat(topResponse.replies().get(0).replies()).isEmpty(); + assertThat(response.page()).isEqualTo(0); + assertThat(response.totalElements()).isEqualTo(1); + } + + @Test + void should_return_null_is_liked_and_false_is_author_when_user_id_not_given() { + // given + Post post = Post.builder().id(POST_ID).build(); + Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 1L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + .willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = commentService.getComments(POST_ID, null, 0, 20); + + // then + CommentResponse topResponse = response.content().get(0); + assertThat(topResponse.isLiked()).isNull(); + assertThat(topResponse.isAuthor()).isFalse(); + } + + @Test + void should_mark_comment_as_liked_when_user_has_liked_it() { + // given + Post post = Post.builder().id(POST_ID).build(); + Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 1L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + .willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(COMMENT_ID))) + .willReturn(List.of(COMMENT_ID)); + + // when + PageResponse response = commentService.getComments(POST_ID, VIEWER_ID, 0, 20); + + // then + assertThat(response.content().get(0).isLiked()).isTrue(); + } + + @Test + void should_replace_content_and_hide_author_when_comment_is_deleted() { + // given + Post post = Post.builder().id(POST_ID).build(); + Comment deletedTopComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제될 댓글", 3L, true); + Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); + Pageable pageable = PageRequest.of(0, 20); + Page topLevelPage = new PageImpl<>(List.of(deletedTopComment), pageable, 1); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + .willReturn(List.of(reply)); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) + .willReturn(List.of(buildUser(AUTHOR_ID, "IU"), buildUser(OTHER_USER_ID, "뷔"))); + given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(COMMENT_ID, REPLY_ID))) + .willReturn(List.of()); + + // when + PageResponse response = commentService.getComments(POST_ID, VIEWER_ID, 0, 20); + + // then + CommentResponse topResponse = response.content().get(0); + assertThat(topResponse.content()).isEqualTo("삭제된 댓글입니다"); + assertThat(topResponse.authorNickname()).isNull(); + assertThat(topResponse.likeCount()).isEqualTo(0L); + assertThat(topResponse.isLiked()).isNull(); + assertThat(topResponse.isAuthor()).isFalse(); + assertThat(topResponse.replies()).hasSize(1); + assertThat(topResponse.replies().get(0).content()).isEqualTo("답글"); + } + + // ------------------------------------------------------------------------- + // create + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_post_does_not_exist_on_create() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + CommentCreateRequest request = new CommentCreateRequest("내용", null); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_save_top_level_comment_and_increment_comment_count_when_parent_comment_id_is_null() { + // given + Post post = Post.builder().id(POST_ID).build(); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.save(any(Comment.class))).willAnswer(invocation -> { + Comment saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", COMMENT_ID); + return saved; + }); + CommentCreateRequest request = new CommentCreateRequest("내용", null); + + // when + CommentCreateResponse response = commentService.create(AUTHOR_ID, POST_ID, request); + + // then + assertThat(response.id()).isEqualTo(COMMENT_ID); + verify(postRepository).incrementCommentCount(POST_ID); + } + + @Test + void should_throw_comment_not_found_exception_when_parent_comment_does_not_exist() { + // given + Post post = Post.builder().id(POST_ID).build(); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.empty()); + CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + verify(commentRepository, never()).save(any(Comment.class)); + verify(postRepository, never()).incrementCommentCount(any()); + } + + @Test + void should_throw_comment_not_found_exception_when_parent_comment_belongs_to_other_post() { + // given + Post post = Post.builder().id(POST_ID).build(); + Comment parent = buildComment(PARENT_ID, OTHER_POST_ID, AUTHOR_ID, null, "다른 게시글 댓글", 0L, false); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_invalid_reply_depth_exception_when_parent_comment_is_already_a_reply() { + // given + Post post = Post.builder().id(POST_ID).build(); + Comment parent = buildComment(PARENT_ID, POST_ID, AUTHOR_ID, COMMENT_ID, "이미 답글인 댓글", 0L, false); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); + + // when & then + assertThatThrownBy(() -> commentService.create(AUTHOR_ID, POST_ID, request)) + .isInstanceOf(InvalidReplyDepthException.class) + .hasMessage(ErrorCode.INVALID_REPLY_DEPTH.getMessage()); + verify(commentRepository, never()).save(any(Comment.class)); + } + + // ------------------------------------------------------------------------- + // delete + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_delete() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.delete(AUTHOR_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_comment_forbidden_exception_when_deleter_is_not_author() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 0L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.delete(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(CommentForbiddenException.class) + .hasMessage(ErrorCode.FORBIDDEN.getMessage()); + } + + @Test + void should_soft_delete_comment_when_author_deletes() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 0L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + commentService.delete(AUTHOR_ID, COMMENT_ID); + + // then + assertThat(comment.isDeleted()).isTrue(); + verify(commentRepository, never()).delete(any(Comment.class)); + } + + // ------------------------------------------------------------------------- + // like + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_like() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.like(AUTHOR_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_already_liked_exception_when_user_already_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.existsByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(true); + + // when & then + assertThatThrownBy(() -> commentService.like(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(AlreadyLikedException.class) + .hasMessage(ErrorCode.ALREADY_LIKED.getMessage()); + verify(commentLikeRepository, never()).save(any(CommentLike.class)); + verify(commentRepository, never()).incrementLikeCount(any()); + } + + @Test + void should_increment_like_count_and_return_incremented_count_when_user_has_not_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.existsByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(false); + + // when + CommentLikeCountResponse response = commentService.like(OTHER_USER_ID, COMMENT_ID); + + // then + assertThat(response.likeCount()).isEqualTo(6L); + verify(commentRepository).incrementLikeCount(COMMENT_ID); + verify(commentLikeRepository).save(any(CommentLike.class)); + } + + // ------------------------------------------------------------------------- + // unlike + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_unlike() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.unlike(AUTHOR_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_not_liked_exception_when_user_has_not_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.findByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.unlike(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(NotLikedException.class) + .hasMessage(ErrorCode.NOT_LIKED.getMessage()); + verify(commentLikeRepository, never()).delete(any(CommentLike.class)); + verify(commentRepository, never()).decrementLikeCount(any()); + } + + @Test + void should_decrement_like_count_and_return_decremented_count_when_user_has_liked_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); + CommentLike like = CommentLike.builder().userId(OTHER_USER_ID).commentId(COMMENT_ID).build(); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + given(commentLikeRepository.findByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(Optional.of(like)); + + // when + CommentLikeCountResponse response = commentService.unlike(OTHER_USER_ID, COMMENT_ID); + + // then + assertThat(response.likeCount()).isEqualTo(4L); + verify(commentLikeRepository).delete(like); + verify(commentRepository).decrementLikeCount(COMMENT_ID); + } +} From 43fc174f9af99c806e7132647507315c2d85c79b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:39:52 +0900 Subject: [PATCH 18/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=20Controller?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostCommentController(GET·POST /api/posts/{id}/comments), CommentController(DELETE /api/comments/{id}, POST·DELETE /api/comments/{id}/like)를 추가한다. 목록 조회는 비인증도 허용한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/controller/CommentController.java | 55 +++++ .../controller/PostCommentController.java | 57 +++++ .../controller/CommentControllerTest.java | 193 ++++++++++++++++ .../controller/PostCommentControllerTest.java | 213 ++++++++++++++++++ 4 files changed, 518 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/post/controller/CommentController.java create mode 100644 src/main/java/com/Coming/Backend/post/controller/PostCommentController.java create mode 100644 src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java diff --git a/src/main/java/com/Coming/Backend/post/controller/CommentController.java b/src/main/java/com/Coming/Backend/post/controller/CommentController.java new file mode 100644 index 0000000..9af008e --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/CommentController.java @@ -0,0 +1,55 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Comment") +@RestController +@RequestMapping("/api/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 삭제") + @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)") + @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND") + @DeleteMapping("/{commentId}") + public ResponseEntity delete( + @AuthenticationPrincipal Long userId, + @PathVariable Long commentId) { + commentService.delete(userId, commentId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "댓글 좋아요") + @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND") + @ApiResponse(responseCode = "409", description = "ALREADY_LIKED") + @PostMapping("/{commentId}/like") + public ResponseEntity like( + @AuthenticationPrincipal Long userId, + @PathVariable Long commentId) { + return ResponseEntity.ok(commentService.like(userId, commentId)); + } + + @Operation(summary = "댓글 좋아요 취소") + @ApiResponse(responseCode = "400", description = "NOT_LIKED") + @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND") + @DeleteMapping("/{commentId}/like") + public ResponseEntity unlike( + @AuthenticationPrincipal Long userId, + @PathVariable Long commentId) { + return ResponseEntity.ok(commentService.unlike(userId, commentId)); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java new file mode 100644 index 0000000..adfd403 --- /dev/null +++ b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java @@ -0,0 +1,57 @@ +package com.Coming.Backend.post.controller; + +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Comment") +@Validated +@RestController +@RequestMapping("/api/posts/{postId}/comments") +@RequiredArgsConstructor +public class PostCommentController { + + private final CommentService commentService; + + @Operation(summary = "게시글 댓글·답글 목록 조회") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND") + @GetMapping + public ResponseEntity> getComments( + @PathVariable Long postId, + @AuthenticationPrincipal Long userId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(commentService.getComments(postId, userId, page, size)); + } + + @Operation(summary = "댓글 또는 답글 작성") + @ApiResponse(responseCode = "400", description = "INVALID_REPLY_DEPTH") + @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND | COMMENT_NOT_FOUND") + @PostMapping + public ResponseEntity create( + @PathVariable Long postId, + @AuthenticationPrincipal Long userId, + @Valid @RequestBody CommentCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(commentService.create(userId, postId, request)); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java new file mode 100644 index 0000000..b6dd055 --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/CommentControllerTest.java @@ -0,0 +1,193 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.post.dto.CommentLikeCountResponse; +import com.Coming.Backend.post.exception.AlreadyLikedException; +import com.Coming.Backend.post.exception.CommentForbiddenException; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.NotLikedException; +import com.Coming.Backend.post.service.CommentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class CommentControllerTest { + + private MockMvc mockMvc; + + @Mock + private CommentService commentService; + + @InjectMocks + private CommentController commentController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long COMMENT_ID = 100L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(commentController) + .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ------------------------------------------------------------------------- + // DELETE /api/comments/{commentId} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_delete_succeeds() throws Exception { + // given + willDoNothing().given(commentService).delete(eq(USER_ID), eq(COMMENT_ID)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void should_return_404_when_comment_not_found_on_delete() throws Exception { + // given + willThrow(new CommentNotFoundException()).given(commentService).delete(eq(USER_ID), eq(999L)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_403_when_not_author_on_delete() throws Exception { + // given + willThrow(new CommentForbiddenException()).given(commentService).delete(eq(USER_ID), eq(COMMENT_ID)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/comments/{commentId}/like + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_like_count_when_like_succeeds() throws Exception { + // given + given(commentService.like(eq(USER_ID), eq(COMMENT_ID))).willReturn(new CommentLikeCountResponse(5L)); + + // when & then + mockMvc.perform(post("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(5)); + } + + @Test + void should_return_404_when_comment_not_found_on_like() throws Exception { + // given + given(commentService.like(eq(USER_ID), eq(999L))).willThrow(new CommentNotFoundException()); + + // when & then + mockMvc.perform(post("/api/comments/{commentId}/like", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_already_liked_on_like() throws Exception { + // given + given(commentService.like(eq(USER_ID), eq(COMMENT_ID))).willThrow(new AlreadyLikedException()); + + // when & then + mockMvc.perform(post("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.ALREADY_LIKED.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/comments/{commentId}/like + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_like_count_when_unlike_succeeds() throws Exception { + // given + given(commentService.unlike(eq(USER_ID), eq(COMMENT_ID))).willReturn(new CommentLikeCountResponse(3L)); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(3)); + } + + @Test + void should_return_404_when_comment_not_found_on_unlike() throws Exception { + // given + given(commentService.unlike(eq(USER_ID), eq(999L))).willThrow(new CommentNotFoundException()); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}/like", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_not_liked_on_unlike() throws Exception { + // given + given(commentService.unlike(eq(USER_ID), eq(COMMENT_ID))).willThrow(new NotLikedException()); + + // when & then + mockMvc.perform(delete("/api/comments/{commentId}/like", COMMENT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOT_LIKED.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java new file mode 100644 index 0000000..771d0cb --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java @@ -0,0 +1,213 @@ +package com.Coming.Backend.post.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.post.dto.CommentCreateRequest; +import com.Coming.Backend.post.dto.CommentCreateResponse; +import com.Coming.Backend.post.dto.CommentResponse; +import com.Coming.Backend.post.exception.CommentNotFoundException; +import com.Coming.Backend.post.exception.InvalidReplyDepthException; +import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.service.CommentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PostCommentControllerTest { + + private MockMvc mockMvc; + + @Mock + private CommentService commentService; + + @InjectMocks + private PostCommentController postCommentController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long POST_ID = 10L; + private static final Long PARENT_COMMENT_ID = 100L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(postCommentController) + .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private CommentResponse sampleComment() { + return new CommentResponse(PARENT_COMMENT_ID, "IU", true, "좋은 게시글이네요", 0L, false, + LocalDateTime.now(), List.of()); + } + + // ------------------------------------------------------------------------- + // GET /api/posts/{postId}/comments + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_comments_when_authenticated_user_requests() throws Exception { + // given + PageResponse pageResponse = new PageResponse<>(List.of(sampleComment()), 0, 20, 1, 1); + given(commentService.getComments(eq(POST_ID), eq(USER_ID), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts/{postId}/comments", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(PARENT_COMMENT_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_return_200_with_comments_when_anonymous_user_requests() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication(null); + PageResponse pageResponse = new PageResponse<>(List.of(sampleComment()), 0, 20, 1, 1); + given(commentService.getComments(eq(POST_ID), isNull(), eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts/{postId}/comments", POST_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void should_return_404_when_post_not_found_on_get_comments() throws Exception { + // given + given(commentService.getComments(eq(999L), eq(USER_ID), eq(0), eq(20))).willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(get("/api/posts/{postId}/comments", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/posts/{postId}/comments + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_create_request_is_valid() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("좋은 글이네요", null); + given(commentService.create(eq(USER_ID), eq(POST_ID), any(CommentCreateRequest.class))) + .willReturn(new CommentCreateResponse(PARENT_COMMENT_ID)); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(PARENT_COMMENT_ID)); + } + + @Test + void should_return_400_when_content_is_blank_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("", null); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_404_when_post_not_found_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("좋은 글이네요", null); + given(commentService.create(eq(USER_ID), eq(999L), any(CommentCreateRequest.class))) + .willThrow(new PostNotFoundException()); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_404_when_parent_comment_not_found_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("답글입니다", 999L); + given(commentService.create(eq(USER_ID), eq(POST_ID), any(CommentCreateRequest.class))) + .willThrow(new CommentNotFoundException()); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMMENT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_reply_to_reply_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("답글의 답글입니다", PARENT_COMMENT_ID); + given(commentService.create(eq(USER_ID), eq(POST_ID), any(CommentCreateRequest.class))) + .willThrow(new InvalidReplyDepthException()); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_REPLY_DEPTH.name())) + .andExpect(jsonPath("$.message").exists()); + } +} From 952e483fdc5a00461cd6a48eccc6da48a87d319d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:44:09 +0900 Subject: [PATCH 19/54] =?UTF-8?q?[test]=20CommentRepository=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=BF=BC=EB=A6=AC=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit be-review 중 CommentServiceTest가 Repository를 전부 mock 처리해 findByParentCommentIdInOrderByCreatedAtAsc·findLikedCommentIds의 실제 DB 동작(특히 빈 컬렉션 IN절 — 댓글 없는 게시글 조회 시 항상 거치는 경로)이 한 번도 실제 PostgreSQL로 검증되지 않은 커버리지 공백을 발견했다. @DataJpaTest로 8개 케이스를 검증한 결과 Hibernate 7.2.12에서는 예외 없이 정상 동작함을 확인했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../repository/CommentRepositoryTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java diff --git a/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java new file mode 100644 index 0000000..cea1ead --- /dev/null +++ b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java @@ -0,0 +1,153 @@ +package com.Coming.Backend.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.post.entity.Comment; +import com.Coming.Backend.post.entity.CommentLike; +import jakarta.persistence.EntityManager; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class CommentRepositoryTest { + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private CommentLikeRepository commentLikeRepository; + + @Autowired + private EntityManager entityManager; + + private static final Long AUTHOR_ID = 1L; + private static final Long POST_ID = 100L; + private static final Long OTHER_POST_ID = 200L; + + private Comment buildComment(Long postId, Long parentCommentId, String content) { + return buildComment(postId, parentCommentId, content, 0L); + } + + private Comment buildComment(Long postId, Long parentCommentId, String content, long likeCount) { + return Comment.builder() + .postId(postId) + .userId(AUTHOR_ID) + .parentCommentId(parentCommentId) + .content(content) + .likeCount(likeCount) + .deleted(false) + .build(); + } + + @Test + void should_return_only_top_level_comments_ordered_by_created_at_when_finding_top_level_by_post_id() { + // given + Comment first = commentRepository.save(buildComment(POST_ID, null, "첫 댓글")); + Comment second = commentRepository.save(buildComment(POST_ID, null, "두번째 댓글")); + commentRepository.save(buildComment(POST_ID, first.getId(), "답글은 제외")); + commentRepository.save(buildComment(OTHER_POST_ID, null, "다른 게시글 댓글")); + + // when + Page result = commentRepository.findTopLevelByPostId(POST_ID, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Comment::getId) + .containsExactly(first.getId(), second.getId()); + } + + @Test + void should_return_empty_page_when_post_has_no_comments() { + // when + Page result = commentRepository.findTopLevelByPostId(POST_ID, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + + @Test + void should_return_replies_ordered_by_created_at_when_parent_ids_given() { + // given + Comment parent = commentRepository.save(buildComment(POST_ID, null, "부모 댓글")); + Comment firstReply = commentRepository.save(buildComment(POST_ID, parent.getId(), "첫 답글")); + Comment secondReply = commentRepository.save(buildComment(POST_ID, parent.getId(), "두번째 답글")); + + // when + List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(parent.getId())); + + // then + assertThat(replies).extracting(Comment::getId) + .containsExactly(firstReply.getId(), secondReply.getId()); + } + + @Test + void should_return_empty_list_without_error_when_parent_ids_is_empty() { + // given + commentRepository.save(buildComment(POST_ID, null, "댓글")); + + // when + List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of()); + + // then + assertThat(replies).isEmpty(); + } + + @Test + void should_increment_like_count_when_incrementing() { + // given + Comment comment = commentRepository.save(buildComment(POST_ID, null, "댓글")); + + // when + commentRepository.incrementLikeCount(comment.getId()); + entityManager.clear(); + Comment updated = commentRepository.findById(comment.getId()).orElseThrow(); + + // then + assertThat(updated.getLikeCount()).isEqualTo(1L); + } + + @Test + void should_decrement_like_count_when_decrementing() { + // given + Comment comment = commentRepository.save(buildComment(POST_ID, null, "댓글", 1L)); + + // when + commentRepository.decrementLikeCount(comment.getId()); + entityManager.clear(); + Comment updated = commentRepository.findById(comment.getId()).orElseThrow(); + + // then + assertThat(updated.getLikeCount()).isZero(); + } + + @Test + void should_return_only_liked_comment_ids_when_finding_liked_comment_ids() { + // given + Comment liked = commentRepository.save(buildComment(POST_ID, null, "좋아요한 댓글")); + Comment notLiked = commentRepository.save(buildComment(POST_ID, null, "좋아요 안한 댓글")); + commentLikeRepository.save(CommentLike.builder().userId(AUTHOR_ID).commentId(liked.getId()).build()); + + // when + List likedIds = commentLikeRepository.findLikedCommentIds(AUTHOR_ID, List.of(liked.getId(), notLiked.getId())); + + // then + assertThat(likedIds).containsExactly(liked.getId()); + } + + @Test + void should_return_empty_list_without_error_when_comment_ids_is_empty_on_finding_liked_comment_ids() { + // when + List likedIds = commentLikeRepository.findLikedCommentIds(AUTHOR_ID, List.of()); + + // then + assertThat(likedIds).isEmpty(); + } +} From a6c899ccc31ec58f8f0d8c415bb20738ef084c8f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 18:58:21 +0900 Subject: [PATCH 20/54] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20/simplify=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify 4개 관점(reuse·simplification·efficiency·altitude) 병렬 리뷰에서 나온 발견 중 아래 항목을 반영했다: - 소프트 삭제 마스킹 정책(content·likeCount·author 노출 여부)을 Comment 엔티티의 도메인 메서드(getDisplayContent 등)로 이동 - getComments/create의 게시글 존재 확인을 findById 대신 existsById로 변경해 불필요한 전체 컬럼 로딩 제거 - 최상위 댓글이 없을 때 답글 조회 쿼리를 생략하는 가드 추가 - 소프트 삭제된 댓글은 닉네임·좋아요 배치 조회 대상에서 제외 - commentCount가 삭제 시 감소하지 않는 이유를 주석으로 명시 UserRepository에 닉네임 배치 조회 default 메서드를 추가해 PostService/CommentService 중복을 제거하는 안은 시도했으나, Mockito @Mock이 인터페이스 default 메서드를 실제로 호출하지 않아 기존 PostServiceTest 다수가 깨지는 것을 확인하고 되돌렸다(의도한 동작 변경 없이 적용 가능한 범위를 벗어남). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Coming/Backend/post/entity/Comment.java | 23 ++++++++++ .../Backend/post/service/CommentService.java | 44 ++++++++++--------- .../post/service/CommentServiceTest.java | 35 ++++++--------- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/entity/Comment.java b/src/main/java/com/Coming/Backend/post/entity/Comment.java index 3cf0cd5..b887c0c 100644 --- a/src/main/java/com/Coming/Backend/post/entity/Comment.java +++ b/src/main/java/com/Coming/Backend/post/entity/Comment.java @@ -43,6 +43,8 @@ public class Comment extends BaseCreatedEntity { @Column(name = "is_deleted", nullable = false) private boolean deleted; + private static final String DELETED_CONTENT_PLACEHOLDER = "삭제된 댓글입니다"; + public boolean isAuthoredBy(Long userId) { return userId != null && this.userId.equals(userId); } @@ -54,4 +56,25 @@ public boolean isReply() { public void softDelete() { this.deleted = true; } + + /** + * 소프트 삭제된 댓글은 본문 대신 플레이스홀더를 노출한다. + */ + public String getDisplayContent() { + return deleted ? DELETED_CONTENT_PLACEHOLDER : content; + } + + /** + * 소프트 삭제된 댓글은 좋아요 수를 0으로 노출한다. + */ + public Long getDisplayLikeCount() { + return deleted ? 0L : likeCount; + } + + /** + * 소프트 삭제된 댓글은 작성자 정보를 노출하지 않는다(isAuthoredBy와 달리 삭제 여부까지 반영). + */ + public boolean isVisibleAuthor(Long userId) { + return !deleted && isAuthoredBy(userId); + } } diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java index e7615d4..21f2484 100644 --- a/src/main/java/com/Coming/Backend/post/service/CommentService.java +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -38,8 +38,6 @@ @Transactional(readOnly = true) public class CommentService { - private static final String DELETED_CONTENT_PLACEHOLDER = "삭제된 댓글입니다"; - private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; private final PostRepository postRepository; @@ -51,23 +49,27 @@ public class CommentService { * @param userId 인증 사용자 ID. null이면 isLiked는 null, isAuthor는 false로 반환된다. */ public PageResponse getComments(Long postId, Long userId, int page, int size) { - postRepository.findById(postId).orElseThrow(PostNotFoundException::new); + if (!postRepository.existsById(postId)) { + throw new PostNotFoundException(); + } Pageable pageable = PageRequest.of(page, size); Page topLevelPage = commentRepository.findTopLevelByPostId(postId, pageable); List topLevelComments = topLevelPage.getContent(); List topLevelIds = topLevelComments.stream().map(Comment::getId).toList(); - Map> repliesByParentId = commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(topLevelIds) - .stream() - .collect(Collectors.groupingBy(Comment::getParentCommentId)); + Map> repliesByParentId = topLevelIds.isEmpty() + ? Map.of() + : commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(topLevelIds).stream() + .collect(Collectors.groupingBy(Comment::getParentCommentId)); - List allComments = Stream.concat( + // 소프트 삭제된 댓글은 닉네임·좋아요 여부를 노출하지 않으므로 배치 조회 대상에서 제외한다. + List visibleComments = Stream.concat( topLevelComments.stream(), repliesByParentId.values().stream().flatMap(List::stream) - ).toList(); - Map nicknameByUserId = findNicknames(allComments); - Set likedCommentIds = findLikedCommentIds(userId, allComments); + ).filter(comment -> !comment.isDeleted()).toList(); + Map nicknameByUserId = findNicknames(visibleComments); + Set likedCommentIds = findLikedCommentIds(userId, visibleComments); List content = topLevelComments.stream() .map(comment -> toResponse(comment, repliesByParentId.getOrDefault(comment.getId(), List.of()), @@ -84,7 +86,9 @@ public PageResponse getComments(Long postId, Long userId, int p */ @Transactional public CommentCreateResponse create(Long userId, Long postId, CommentCreateRequest request) { - postRepository.findById(postId).orElseThrow(PostNotFoundException::new); + if (!postRepository.existsById(postId)) { + throw new PostNotFoundException(); + } Long parentCommentId = request.parentCommentId(); if (parentCommentId != null) { @@ -105,6 +109,7 @@ public CommentCreateResponse create(Long userId, Long postId, CommentCreateReque .deleted(false) .build(); commentRepository.save(comment); + // commentCount는 생성 시에만 증가하는 단조 카운터다. 삭제는 항상 소프트 삭제(행 유지)라 감소시키지 않는다. postRepository.incrementCommentCount(postId); return new CommentCreateResponse(comment.getId()); @@ -163,17 +168,16 @@ private CommentResponse toResponse(Comment comment, List replies, Long .map(reply -> toResponse(reply, List.of(), userId, nicknameByUserId, likedCommentIds)) .toList(); - if (comment.isDeleted()) { - return new CommentResponse(comment.getId(), null, false, DELETED_CONTENT_PLACEHOLDER, 0L, null, - comment.getCreatedAt(), replyResponses); - } + String authorNickname = comment.isDeleted() ? null : nicknameByUserId.get(comment.getUserId()); + Boolean isLiked = userId == null || comment.isDeleted() ? null : likedCommentIds.contains(comment.getId()); + return new CommentResponse( comment.getId(), - nicknameByUserId.get(comment.getUserId()), - comment.isAuthoredBy(userId), - comment.getContent(), - comment.getLikeCount(), - userId == null ? null : likedCommentIds.contains(comment.getId()), + authorNickname, + comment.isVisibleAuthor(userId), + comment.getDisplayContent(), + comment.getDisplayLikeCount(), + isLiked, comment.getCreatedAt(), replyResponses ); diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java index 0b26c48..1974d6e 100644 --- a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -10,7 +10,6 @@ import com.Coming.Backend.post.dto.CommentResponse; import com.Coming.Backend.post.entity.Comment; import com.Coming.Backend.post.entity.CommentLike; -import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.exception.AlreadyLikedException; import com.Coming.Backend.post.exception.CommentForbiddenException; import com.Coming.Backend.post.exception.CommentNotFoundException; @@ -92,7 +91,7 @@ private User buildUser(Long id, String nickname) { @Test void should_throw_post_not_found_exception_when_post_does_not_exist_on_get_comments() { // given - given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + given(postRepository.existsById(POST_ID)).willReturn(false); // when & then assertThatThrownBy(() -> commentService.getComments(POST_ID, null, 0, 20)) @@ -103,13 +102,12 @@ void should_throw_post_not_found_exception_when_post_does_not_exist_on_get_comme @Test void should_return_top_level_comments_with_nested_replies_when_comments_exist() { // given - Post post = Post.builder().id(POST_ID).build(); Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "탑레벨 댓글", 2L, false); Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); Pageable pageable = PageRequest.of(0, 20); Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) .willReturn(List.of(reply)); @@ -133,12 +131,11 @@ void should_return_top_level_comments_with_nested_replies_when_comments_exist() @Test void should_return_null_is_liked_and_false_is_author_when_user_id_not_given() { // given - Post post = Post.builder().id(POST_ID).build(); Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 1L, false); Pageable pageable = PageRequest.of(0, 20); Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) .willReturn(List.of()); @@ -156,12 +153,11 @@ void should_return_null_is_liked_and_false_is_author_when_user_id_not_given() { @Test void should_mark_comment_as_liked_when_user_has_liked_it() { // given - Post post = Post.builder().id(POST_ID).build(); Comment topComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 1L, false); Pageable pageable = PageRequest.of(0, 20); Page topLevelPage = new PageImpl<>(List.of(topComment), pageable, 1); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) .willReturn(List.of()); @@ -179,19 +175,18 @@ void should_mark_comment_as_liked_when_user_has_liked_it() { @Test void should_replace_content_and_hide_author_when_comment_is_deleted() { // given - Post post = Post.builder().id(POST_ID).build(); Comment deletedTopComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제될 댓글", 3L, true); Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); Pageable pageable = PageRequest.of(0, 20); Page topLevelPage = new PageImpl<>(List.of(deletedTopComment), pageable, 1); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) .willReturn(List.of(reply)); - given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) - .willReturn(List.of(buildUser(AUTHOR_ID, "IU"), buildUser(OTHER_USER_ID, "뷔"))); - given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(COMMENT_ID, REPLY_ID))) + given(userRepository.findAllByIdIn(Set.of(OTHER_USER_ID))) + .willReturn(List.of(buildUser(OTHER_USER_ID, "뷔"))); + given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(REPLY_ID))) .willReturn(List.of()); // when @@ -215,7 +210,7 @@ void should_replace_content_and_hide_author_when_comment_is_deleted() { @Test void should_throw_post_not_found_exception_when_post_does_not_exist_on_create() { // given - given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + given(postRepository.existsById(POST_ID)).willReturn(false); CommentCreateRequest request = new CommentCreateRequest("내용", null); // when & then @@ -227,8 +222,7 @@ void should_throw_post_not_found_exception_when_post_does_not_exist_on_create() @Test void should_save_top_level_comment_and_increment_comment_count_when_parent_comment_id_is_null() { // given - Post post = Post.builder().id(POST_ID).build(); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.save(any(Comment.class))).willAnswer(invocation -> { Comment saved = invocation.getArgument(0); ReflectionTestUtils.setField(saved, "id", COMMENT_ID); @@ -247,8 +241,7 @@ void should_save_top_level_comment_and_increment_comment_count_when_parent_comme @Test void should_throw_comment_not_found_exception_when_parent_comment_does_not_exist() { // given - Post post = Post.builder().id(POST_ID).build(); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findById(PARENT_ID)).willReturn(Optional.empty()); CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); @@ -263,9 +256,8 @@ void should_throw_comment_not_found_exception_when_parent_comment_does_not_exist @Test void should_throw_comment_not_found_exception_when_parent_comment_belongs_to_other_post() { // given - Post post = Post.builder().id(POST_ID).build(); Comment parent = buildComment(PARENT_ID, OTHER_POST_ID, AUTHOR_ID, null, "다른 게시글 댓글", 0L, false); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); @@ -278,9 +270,8 @@ void should_throw_comment_not_found_exception_when_parent_comment_belongs_to_oth @Test void should_throw_invalid_reply_depth_exception_when_parent_comment_is_already_a_reply() { // given - Post post = Post.builder().id(POST_ID).build(); Comment parent = buildComment(PARENT_ID, POST_ID, AUTHOR_ID, COMMENT_ID, "이미 답글인 댓글", 0L, false); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); CommentCreateRequest request = new CommentCreateRequest("답글 내용", PARENT_ID); From 2ccd465f014ef69389eaf9319062043393366c94 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 23:16:46 +0900 Subject: [PATCH 21/54] =?UTF-8?q?[feat]=20=EB=8C=93=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20content=20500=EC=9E=90=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 클라이언트 검증 우회 방지를 위한 서버 측 유효성 검사. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/dto/CommentCreateRequest.java | 2 ++ .../post/controller/PostCommentControllerTest.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java index c8afc7e..dacbf3a 100644 --- a/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java +++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java @@ -1,9 +1,11 @@ package com.Coming.Backend.post.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; public record CommentCreateRequest( @NotBlank + @Size(max = 500, message = "댓글은 500자를 초과할 수 없습니다") String content, Long parentCommentId diff --git a/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java index 771d0cb..7a39d1a 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java @@ -163,6 +163,20 @@ void should_return_400_when_content_is_blank_on_create() throws Exception { .andExpect(jsonPath("$.message").exists()); } + @Test + void should_return_400_when_content_exceeds_max_length_on_create() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("가".repeat(501), null); + + // when & then + mockMvc.perform(post("/api/posts/{postId}/comments", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + @Test void should_return_404_when_post_not_found_on_create() throws Exception { // given From a979fc829c295e4a1fdd1284e7679424b0bf5014 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 11 Sep 2026 23:46:15 +0900 Subject: [PATCH 22/54] =?UTF-8?q?[fix]=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EB=8F=84=20=EC=9E=91=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=C2=B7=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=88=98=EB=8A=94=20=EB=85=B8=EC=B6=9C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본문만 플레이스홀더로 대체하고, 닉네임과 좋아요 수는 삭제 여부와 무관하게 실제 값을 반환한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/post/entity/Comment.java | 7 ------- .../Backend/post/service/CommentService.java | 16 +++++++++------- .../Backend/post/service/CommentServiceTest.java | 10 +++++----- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/entity/Comment.java b/src/main/java/com/Coming/Backend/post/entity/Comment.java index b887c0c..dde5037 100644 --- a/src/main/java/com/Coming/Backend/post/entity/Comment.java +++ b/src/main/java/com/Coming/Backend/post/entity/Comment.java @@ -64,13 +64,6 @@ public String getDisplayContent() { return deleted ? DELETED_CONTENT_PLACEHOLDER : content; } - /** - * 소프트 삭제된 댓글은 좋아요 수를 0으로 노출한다. - */ - public Long getDisplayLikeCount() { - return deleted ? 0L : likeCount; - } - /** * 소프트 삭제된 댓글은 작성자 정보를 노출하지 않는다(isAuthoredBy와 달리 삭제 여부까지 반영). */ diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java index 21f2484..97c7e89 100644 --- a/src/main/java/com/Coming/Backend/post/service/CommentService.java +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -63,13 +63,15 @@ public PageResponse getComments(Long postId, Long userId, int p : commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(topLevelIds).stream() .collect(Collectors.groupingBy(Comment::getParentCommentId)); - // 소프트 삭제된 댓글은 닉네임·좋아요 여부를 노출하지 않으므로 배치 조회 대상에서 제외한다. - List visibleComments = Stream.concat( + List allComments = Stream.concat( topLevelComments.stream(), repliesByParentId.values().stream().flatMap(List::stream) - ).filter(comment -> !comment.isDeleted()).toList(); - Map nicknameByUserId = findNicknames(visibleComments); - Set likedCommentIds = findLikedCommentIds(userId, visibleComments); + ).toList(); + Map nicknameByUserId = findNicknames(allComments); + + // 소프트 삭제된 댓글은 좋아요 여부를 노출하지 않으므로 배치 조회 대상에서 제외한다. + List notDeletedComments = allComments.stream().filter(comment -> !comment.isDeleted()).toList(); + Set likedCommentIds = findLikedCommentIds(userId, notDeletedComments); List content = topLevelComments.stream() .map(comment -> toResponse(comment, repliesByParentId.getOrDefault(comment.getId(), List.of()), @@ -168,7 +170,7 @@ private CommentResponse toResponse(Comment comment, List replies, Long .map(reply -> toResponse(reply, List.of(), userId, nicknameByUserId, likedCommentIds)) .toList(); - String authorNickname = comment.isDeleted() ? null : nicknameByUserId.get(comment.getUserId()); + String authorNickname = nicknameByUserId.get(comment.getUserId()); Boolean isLiked = userId == null || comment.isDeleted() ? null : likedCommentIds.contains(comment.getId()); return new CommentResponse( @@ -176,7 +178,7 @@ private CommentResponse toResponse(Comment comment, List replies, Long authorNickname, comment.isVisibleAuthor(userId), comment.getDisplayContent(), - comment.getDisplayLikeCount(), + comment.getLikeCount(), isLiked, comment.getCreatedAt(), replyResponses diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java index 1974d6e..46f5428 100644 --- a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -173,7 +173,7 @@ void should_mark_comment_as_liked_when_user_has_liked_it() { } @Test - void should_replace_content_and_hide_author_when_comment_is_deleted() { + void should_replace_content_but_show_nickname_and_like_count_when_comment_is_deleted() { // given Comment deletedTopComment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제될 댓글", 3L, true); Comment reply = buildComment(REPLY_ID, POST_ID, OTHER_USER_ID, COMMENT_ID, "답글", 0L, false); @@ -184,8 +184,8 @@ void should_replace_content_and_hide_author_when_comment_is_deleted() { given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) .willReturn(List.of(reply)); - given(userRepository.findAllByIdIn(Set.of(OTHER_USER_ID))) - .willReturn(List.of(buildUser(OTHER_USER_ID, "뷔"))); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) + .willReturn(List.of(buildUser(AUTHOR_ID, "지민"), buildUser(OTHER_USER_ID, "뷔"))); given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(REPLY_ID))) .willReturn(List.of()); @@ -195,8 +195,8 @@ void should_replace_content_and_hide_author_when_comment_is_deleted() { // then CommentResponse topResponse = response.content().get(0); assertThat(topResponse.content()).isEqualTo("삭제된 댓글입니다"); - assertThat(topResponse.authorNickname()).isNull(); - assertThat(topResponse.likeCount()).isEqualTo(0L); + assertThat(topResponse.authorNickname()).isEqualTo("지민"); + assertThat(topResponse.likeCount()).isEqualTo(3L); assertThat(topResponse.isLiked()).isNull(); assertThat(topResponse.isAuthor()).isFalse(); assertThat(topResponse.replies()).hasSize(1); From 18d2b1b1132fe9a2c8bab88bffde3599d08226b8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:24:01 +0900 Subject: [PATCH 23/54] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EB=8C=93=EA=B8=80=C2=B7?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=C2=B7=EC=B6=94=EC=B2=9C=20=EC=97=B0?= =?UTF-8?q?=EC=87=84=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게시글이 삭제돼도 딸린 comment·comment_like·post_recommend 행이 정리되지 않아 orphan 데이터로 남는 문제를 수정한다. 이 저장소의 다른 마이그레이션은 전부 ON DELETE CASCADE 없는 FK만 사용하므로, DB 제약 대신 PostService.delete()에서 comment_like → comment → post_recommend → post_entity_tag → post 순으로 명시적으로 삭제한다. 같은 CommentRepository 변경에 묶여 댓글 목록 조회 정렬에 id 보조 정렬을 추가해 createdAt이 동일한 댓글이 페이지 경계에서 뒤섞이지 않도록 한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../repository/CommentLikeRepository.java | 2 + .../post/repository/CommentRepository.java | 12 +++- .../repository/PostRecommendRepository.java | 2 + .../Backend/post/service/CommentService.java | 2 +- .../Backend/post/service/PostService.java | 11 ++++ .../repository/CommentRepositoryTest.java | 66 ++++++++++++++++++- .../post/service/CommentServiceTest.java | 8 +-- .../Backend/post/service/PostServiceTest.java | 41 ++++++++++++ 8 files changed, 135 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java index ffbade6..4b80c7a 100644 --- a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java @@ -16,4 +16,6 @@ public interface CommentLikeRepository extends JpaRepository @Query("SELECT cl.commentId FROM CommentLike cl WHERE cl.userId = :userId AND cl.commentId IN :commentIds") List findLikedCommentIds(@Param("userId") Long userId, @Param("commentIds") Collection commentIds); + + void deleteByCommentIdIn(Collection commentIds); } diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java index a752344..7fa285a 100644 --- a/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java @@ -12,10 +12,15 @@ public interface CommentRepository extends JpaRepository { - @Query("SELECT c FROM Comment c WHERE c.postId = :postId AND c.parentCommentId IS NULL ORDER BY c.createdAt ASC") + @Query("SELECT c FROM Comment c WHERE c.postId = :postId AND c.parentCommentId IS NULL ORDER BY c.createdAt ASC, c.id ASC") Page findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable); - List findByParentCommentIdInOrderByCreatedAtAsc(Collection parentCommentIds); + List findByParentCommentIdInOrderByCreatedAtAscIdAsc(Collection parentCommentIds); + + @Query("SELECT c.id FROM Comment c WHERE c.postId = :postId") + List findIdsByPostId(@Param("postId") Long postId); + + void deleteByPostId(Long postId); @Modifying @Query("UPDATE Comment c SET c.likeCount = c.likeCount + 1 WHERE c.id = :id") @@ -24,4 +29,7 @@ public interface CommentRepository extends JpaRepository { @Modifying @Query("UPDATE Comment c SET c.likeCount = c.likeCount - 1 WHERE c.id = :id") void decrementLikeCount(@Param("id") Long id); + + @Query("SELECT c.likeCount FROM Comment c WHERE c.id = :id") + Long findLikeCountById(@Param("id") Long id); } diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java index a8af27a..25f17ea 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java @@ -9,4 +9,6 @@ public interface PostRecommendRepository extends JpaRepository findByUserIdAndPostId(Long userId, Long postId); + + void deleteByPostId(Long postId); } diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java index 97c7e89..0b877a5 100644 --- a/src/main/java/com/Coming/Backend/post/service/CommentService.java +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -60,7 +60,7 @@ public PageResponse getComments(Long postId, Long userId, int p List topLevelIds = topLevelComments.stream().map(Comment::getId).toList(); Map> repliesByParentId = topLevelIds.isEmpty() ? Map.of() - : commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(topLevelIds).stream() + : commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(topLevelIds).stream() .collect(Collectors.groupingBy(Comment::getParentCommentId)); List allComments = Stream.concat( diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index c80885b..0149a58 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -24,6 +24,8 @@ import com.Coming.Backend.post.exception.PostContentTooLongException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; import com.Coming.Backend.post.repository.EntityTagCount; import com.Coming.Backend.post.repository.PostEntityTagRepository; import com.Coming.Backend.post.repository.PostRecommendRepository; @@ -54,6 +56,8 @@ public class PostService { private final PostRepository postRepository; private final PostEntityTagRepository postEntityTagRepository; private final PostRecommendRepository postRecommendRepository; + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; private final UserRepository userRepository; private final EntityLookupService entityLookupService; @@ -255,6 +259,7 @@ public void update(Long userId, Long id, PostUpdateRequest request) { /** * 게시글을 삭제한다. 작성자 본인만 삭제할 수 있다. + * 게시글이 사라지면 그 밑의 댓글·좋아요·추천은 다시 보여줄 곳이 없으므로 함께 물리 삭제한다. */ @Transactional public void delete(Long userId, Long id) { @@ -262,6 +267,12 @@ public void delete(Long userId, Long id) { if (!post.isAuthoredBy(userId)) { throw new PostForbiddenException(); } + List commentIds = commentRepository.findIdsByPostId(id); + if (!commentIds.isEmpty()) { + commentLikeRepository.deleteByCommentIdIn(commentIds); + } + commentRepository.deleteByPostId(id); + postRecommendRepository.deleteByPostId(id); postEntityTagRepository.deleteByPostId(id); postRepository.delete(post); } diff --git a/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java index cea1ead..9d6d646 100644 --- a/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java @@ -81,7 +81,7 @@ void should_return_replies_ordered_by_created_at_when_parent_ids_given() { Comment secondReply = commentRepository.save(buildComment(POST_ID, parent.getId(), "두번째 답글")); // when - List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(parent.getId())); + List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(parent.getId())); // then assertThat(replies).extracting(Comment::getId) @@ -94,7 +94,7 @@ void should_return_empty_list_without_error_when_parent_ids_is_empty() { commentRepository.save(buildComment(POST_ID, null, "댓글")); // when - List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of()); + List replies = commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of()); // then assertThat(replies).isEmpty(); @@ -150,4 +150,66 @@ void should_return_empty_list_without_error_when_comment_ids_is_empty_on_finding // then assertThat(likedIds).isEmpty(); } + + @Test + void should_return_only_comment_ids_belonging_to_post_when_finding_ids_by_post_id() { + // given + Comment first = commentRepository.save(buildComment(POST_ID, null, "댓글1")); + Comment second = commentRepository.save(buildComment(POST_ID, first.getId(), "댓글2")); + commentRepository.save(buildComment(OTHER_POST_ID, null, "다른 게시글 댓글")); + + // when + List ids = commentRepository.findIdsByPostId(POST_ID); + + // then + assertThat(ids).containsExactlyInAnyOrder(first.getId(), second.getId()); + } + + @Test + void should_delete_only_comments_belonging_to_post_when_deleting_by_post_id() { + // given + Comment target = commentRepository.save(buildComment(POST_ID, null, "삭제될 댓글")); + Comment other = commentRepository.save(buildComment(OTHER_POST_ID, null, "유지될 댓글")); + + // when + commentRepository.deleteByPostId(POST_ID); + entityManager.flush(); + entityManager.clear(); + + // then + assertThat(commentRepository.findById(target.getId())).isEmpty(); + assertThat(commentRepository.findById(other.getId())).isPresent(); + } + + @Test + void should_delete_only_likes_for_given_comment_ids_when_deleting_by_comment_id_in() { + // given + Comment target = commentRepository.save(buildComment(POST_ID, null, "댓글")); + Comment other = commentRepository.save(buildComment(POST_ID, null, "다른 댓글")); + CommentLike targetLike = commentLikeRepository.save( + CommentLike.builder().userId(AUTHOR_ID).commentId(target.getId()).build()); + CommentLike otherLike = commentLikeRepository.save( + CommentLike.builder().userId(AUTHOR_ID).commentId(other.getId()).build()); + + // when + commentLikeRepository.deleteByCommentIdIn(List.of(target.getId())); + entityManager.flush(); + entityManager.clear(); + + // then + assertThat(commentLikeRepository.findById(targetLike.getId())).isEmpty(); + assertThat(commentLikeRepository.findById(otherLike.getId())).isPresent(); + } + + @Test + void should_return_current_like_count_when_finding_like_count_by_id() { + // given + Comment comment = commentRepository.save(buildComment(POST_ID, null, "댓글", 3L)); + + // when + Long likeCount = commentRepository.findLikeCountById(comment.getId()); + + // then + assertThat(likeCount).isEqualTo(3L); + } } diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java index 46f5428..4ebf64f 100644 --- a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -109,7 +109,7 @@ void should_return_top_level_comments_with_nested_replies_when_comments_exist() given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); - given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) .willReturn(List.of(reply)); given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) .willReturn(List.of(buildUser(AUTHOR_ID, "IU"), buildUser(OTHER_USER_ID, "뷔"))); @@ -137,7 +137,7 @@ void should_return_null_is_liked_and_false_is_author_when_user_id_not_given() { given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); - given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) .willReturn(List.of()); given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); @@ -159,7 +159,7 @@ void should_mark_comment_as_liked_when_user_has_liked_it() { given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); - given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) .willReturn(List.of()); given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(COMMENT_ID))) @@ -182,7 +182,7 @@ void should_replace_content_but_show_nickname_and_like_count_when_comment_is_del given(postRepository.existsById(POST_ID)).willReturn(true); given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage); - given(commentRepository.findByParentCommentIdInOrderByCreatedAtAsc(List.of(COMMENT_ID))) + given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(List.of(COMMENT_ID))) .willReturn(List.of(reply)); given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID, OTHER_USER_ID))) .willReturn(List.of(buildUser(AUTHOR_ID, "지민"), buildUser(OTHER_USER_ID, "뷔"))); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 64f563b..41023eb 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -34,6 +34,8 @@ import com.Coming.Backend.post.exception.PostContentTooLongException; import com.Coming.Backend.post.exception.PostForbiddenException; import com.Coming.Backend.post.exception.PostNotFoundException; +import com.Coming.Backend.post.repository.CommentLikeRepository; +import com.Coming.Backend.post.repository.CommentRepository; import com.Coming.Backend.post.repository.EntityTagCount; import com.Coming.Backend.post.repository.PostEntityTagRepository; import com.Coming.Backend.post.repository.PostRecommendRepository; @@ -72,6 +74,12 @@ class PostServiceTest { @Mock private PostRecommendRepository postRecommendRepository; + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentLikeRepository commentLikeRepository; + @Mock private UserRepository userRepository; @@ -757,6 +765,7 @@ void should_delete_post_and_entity_tags_when_author_deletes() { // given Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findIdsByPostId(POST_ID)).willReturn(List.of()); // when postService.delete(AUTHOR_ID, POST_ID); @@ -766,6 +775,38 @@ void should_delete_post_and_entity_tags_when_author_deletes() { verify(postRepository).delete(post); } + @Test + void should_delete_comments_comment_likes_and_recommends_when_author_deletes_post() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + List commentIds = List.of(1L, 2L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findIdsByPostId(POST_ID)).willReturn(commentIds); + + // when + postService.delete(AUTHOR_ID, POST_ID); + + // then + verify(commentLikeRepository).deleteByCommentIdIn(commentIds); + verify(commentRepository).deleteByPostId(POST_ID); + verify(postRecommendRepository).deleteByPostId(POST_ID); + } + + @Test + void should_not_call_comment_like_delete_when_post_has_no_comments() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(commentRepository.findIdsByPostId(POST_ID)).willReturn(List.of()); + + // when + postService.delete(AUTHOR_ID, POST_ID); + + // then + verify(commentLikeRepository, never()).deleteByCommentIdIn(any()); + verify(commentRepository).deleteByPostId(POST_ID); + } + // ------------------------------------------------------------------------- // recommend // ------------------------------------------------------------------------- From 51fcb4cb2eb14699bf7274b08e2cda2dd6887890 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:24:46 +0900 Subject: [PATCH 24/54] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20(=EB=B3=B8=EB=AC=B8=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=9A=B0=ED=9A=8C=20=EB=B0=A9=EC=A7=80,=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=20=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84,=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content_text 글자수(1만자) 제한은 Tiptap JSON에서 추출한 텍스트만 검증해, 텍스트는 짧고 구조(attrs 등)만 방대한 요청으로 우회할 수 있었다. 직렬화된 content 원본 문자열 크기(5만자)에도 별도 상한을 둔다. - 게시글 통합 검색·멘션 자동완성에서 검색어에 포함된 %, _가 LIKE 와일드카드로 해석돼 결과가 부정확해지는 문제를 백슬래시 이스케이프로 수정한다. PostgreSQL의 LIKE 기본 이스케이프 문자가 \이므로 JPQL 쿼리 자체는 건드리지 않는다. - 같은 (entityType, entityId) 태그를 중복 요청하면 post_entity_tag에 중복 행이 쌓이던 문제를 생성 시 distinct 처리로 방지한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/service/MentionService.java | 8 ++- .../Backend/post/service/PostService.java | 29 ++++++++- .../post/service/MentionServiceTest.java | 13 ++++ .../Backend/post/service/PostServiceTest.java | 64 +++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index 0609cd6..131b844 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -72,6 +72,12 @@ private List searchReleases(String q, Pageable pageable) { } private String toLikePattern(String q) { - return "%" + q.toLowerCase(Locale.ROOT) + "%"; + return "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; + } + + private String escapeLikeWildcards(String q) { + return q.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); } } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 0149a58..63ab86c 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -69,6 +69,12 @@ public class PostService { private static final int MAX_CONTENT_TEXT_LENGTH = 10000; + /** + * content 원본(jsonb 직렬화 문자열) 크기 상한. contentText는 텍스트 노드만 추출한 값이라 + * 구조만 방대한 JSON으로 MAX_CONTENT_TEXT_LENGTH 검증을 우회할 수 있어 별도로 제한한다. + */ + private static final int MAX_CONTENT_LENGTH = 50000; + /** * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 @@ -80,12 +86,14 @@ public PostCreateResponse create(Long userId, PostCreateRequest request) { String contentText = TiptapTextExtractor.extract(request.content()); validateContentTextLength(contentText); + String content = writeContent(request.content()); + validateContentLength(content); Post post = Post.builder() .userId(userId) .category(request.category()) .title(request.title()) - .content(writeContent(request.content())) + .content(content) .contentText(contentText) .recommendCount(0L) .viewCount(0L) @@ -226,7 +234,7 @@ public List getTrendingTags(int days, int limit) { */ public PageResponse search(String q, int page, int size) { Pageable pageable = PageRequest.of(page, size); - String likeQ = "%" + q.toLowerCase(Locale.ROOT) + "%"; + String likeQ = "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; Page result = postRepository.searchPosts(likeQ, pageable); List content = toSummaryResponses(result.getContent()); return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); @@ -249,6 +257,9 @@ public void update(Long userId, Long id, PostUpdateRequest request) { validateContentTextLength(contentText); } String content = request.content() != null ? writeContent(request.content()) : null; + if (content != null) { + validateContentLength(content); + } post.update(request.category(), request.title(), content, contentText); if (request.entityTags() != null) { @@ -317,6 +328,18 @@ private void validateContentTextLength(String contentText) { } } + private void validateContentLength(String content) { + if (content.length() > MAX_CONTENT_LENGTH) { + throw new PostContentTooLongException(); + } + } + + private String escapeLikeWildcards(String q) { + return q.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } + private String writeContent(Object content) { try { return contentObjectMapper.writeValueAsString(content); @@ -334,7 +357,7 @@ private Object readContent(String content) { } private void saveEntityTags(Long postId, List tags) { - tags.forEach(tag -> postEntityTagRepository.save(PostEntityTag.builder() + tags.stream().distinct().forEach(tag -> postEntityTagRepository.save(PostEntityTag.builder() .postId(postId) .entityType(tag.entityType()) .entityId(tag.entityId()) diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index bf00edf..b31e85a 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -120,4 +120,17 @@ void should_return_empty_list_when_no_search_result_found() { // then assertThat(result).isEmpty(); } + + @Test + void should_escape_like_wildcards_when_query_contains_percent_and_underscore() { + // given + given(concertRepository.searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class))) + .willReturn(List.of()); + + // when + mentionService.search(EntityType.CONCERT, "50%_off", LIMIT); + + // then + verify(concertRepository).searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class)); + } } diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 41023eb..ae0282a 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -119,6 +119,23 @@ private Map contentWithLength(int length) { ); } + /** + * 텍스트 노드는 짧지만(contentText 검증 통과), attrs에 padding을 채워 직렬화된 JSON 문자열 자체는 + * 크게 만든 Tiptap 문서. content 원본 크기 상한 검증용. + */ + private Map contentWithLargeStructureAndShortText(int paddingLength) { + return Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", + "attrs", Map.of("padding", "x".repeat(paddingLength)), + "content", List.of( + Map.of("type", "text", "text", "안녕하세요") + )) + ) + ); + } + private Post buildPost(Long id, Long userId, PostCategory category, String title, long viewCount) { return Post.builder() .id(id) @@ -221,6 +238,27 @@ void should_save_entity_tags_when_review_category_given_with_entity_tags() { verify(postEntityTagRepository, times(2)).save(any(PostEntityTag.class)); } + @Test + void should_deduplicate_entity_tags_when_duplicate_tags_given_on_create() { + // given + List tags = List.of( + new EntityTagRequest(EntityType.ARTIST, 1L), + new EntityTagRequest(EntityType.ARTIST, 1L) + ); + PostCreateRequest request = new PostCreateRequest(PostCategory.REVIEW, "리뷰 제목", sampleContent(), tags); + given(postRepository.save(any(Post.class))).willAnswer(invocation -> { + Post saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", POST_ID); + return saved; + }); + + // when + postService.create(AUTHOR_ID, request); + + // then + verify(postEntityTagRepository, times(1)).save(any(PostEntityTag.class)); + } + @Test void should_create_post_when_content_text_length_is_exactly_max_length() { // given @@ -251,6 +289,19 @@ void should_throw_content_too_long_exception_when_content_text_length_exceeds_ma verify(postRepository, never()).save(any(Post.class)); } + @Test + void should_throw_content_too_long_exception_when_raw_content_size_exceeds_limit_despite_short_text_on_create() { + // given: contentText는 10000자 제한을 통과할 만큼 짧지만, 구조(attrs)만 방대해 직렬화된 content가 50000자를 초과한다. + PostCreateRequest request = new PostCreateRequest(PostCategory.FREE, "제목", + contentWithLargeStructureAndShortText(60000), null); + + // when & then + assertThatThrownBy(() -> postService.create(AUTHOR_ID, request)) + .isInstanceOf(PostContentTooLongException.class) + .hasMessage(ErrorCode.POST_CONTENT_TOO_LONG.getMessage()); + verify(postRepository, never()).save(any(Post.class)); + } + // ------------------------------------------------------------------------- // getDetail // ------------------------------------------------------------------------- @@ -595,6 +646,19 @@ void should_return_empty_content_when_no_search_result_found() { assertThat(response.totalElements()).isZero(); } + @Test + void should_escape_like_wildcards_when_search_query_contains_percent_and_underscore() { + // given + Pageable pageable = PageRequest.of(0, 20); + given(postRepository.searchPosts(eq("%50\\%\\_off%"), eq(pageable))).willReturn(Page.empty(pageable)); + + // when + postService.search("50%_off", 0, 20); + + // then + verify(postRepository).searchPosts("%50\\%\\_off%", pageable); + } + // ------------------------------------------------------------------------- // update // ------------------------------------------------------------------------- From 691d404bb3604cf75257ecbb2021c08086c9143d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:25:01 +0900 Subject: [PATCH 25/54] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=C2=B7=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EC=99=84=20(entityTags=20null?= =?UTF-8?q?=20=EC=9B=90=EC=86=8C,=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EB=AA=A9=20=EA=B1=B0=EB=B6=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entityTags 리스트에 null 원소가 오면 @Valid가 건너뛰어 PostService가 그대로 tag.entityType()을 호출해 NPE가 날 수 있었다. 원소 타입에 @NotNull을 추가해 요청 단계에서 거부한다. - PostUpdateRequest.title은 @Size만 있어 공백 문자열로 덮어쓸 수 있었다(생성 시엔 @NotBlank로 막혀 있음). null(미변경)은 허용하되 공백만 거부하도록 @Pattern을 추가한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/dto/PostCreateRequest.java | 2 +- .../Backend/post/dto/PostUpdateRequest.java | 5 +++- .../post/controller/PostControllerTest.java | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java index 1b6205b..9581d77 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java @@ -20,6 +20,6 @@ public record PostCreateRequest( Object content, @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다") - List<@Valid EntityTagRequest> entityTags + List<@NotNull @Valid EntityTagRequest> entityTags ) { } diff --git a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java index d02aaf9..8f19709 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java @@ -2,6 +2,8 @@ import com.Coming.Backend.post.entity.PostCategory; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.util.List; @@ -9,12 +11,13 @@ public record PostUpdateRequest( PostCategory category, + @Pattern(regexp = "(?s).*\\S.*", message = "제목은 공백일 수 없습니다") @Size(max = 255) String title, Object content, @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다") - List<@Valid EntityTagRequest> entityTags + List<@NotNull @Valid EntityTagRequest> entityTags ) { } diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index e2295db..615eecd 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -152,6 +152,21 @@ void should_return_400_when_create_request_has_more_than_10_entity_tags() throws .andExpect(jsonPath("$.message").value(containsString("태그는 10개를 초과할 수 없습니다"))); } + @Test + void should_return_400_when_create_request_has_null_entity_tag_element() throws Exception { + // given: EntityTagRequest 대신 null이 섞인 요청을 직접 JSON으로 구성한다. + String requestJson = """ + {"category":"FREE","title":"자유 제목","content":{"type":"doc","content":[]},"entityTags":[null]} + """; + + // when & then + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + @Test void should_return_201_when_create_request_has_exactly_10_entity_tags() throws Exception { // given @@ -293,6 +308,19 @@ void should_return_403_when_service_throws_forbidden_exception_on_update() throw .andExpect(jsonPath("$.message").exists()); } + @Test + void should_return_400_when_update_request_has_blank_title() throws Exception { + // given + PostUpdateRequest request = new PostUpdateRequest(null, " ", null, null); + + // when & then + mockMvc.perform(patch("/api/posts/{id}", POST_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + @Test void should_return_400_when_update_request_has_more_than_10_entity_tags() throws Exception { // given From 039b29ca19450aa860fc0242b855acd4fbd5cc19 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:25:15 +0900 Subject: [PATCH 26/54] =?UTF-8?q?[fix]=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EB=B0=8F=20=EC=A2=8B=EC=95=84=EC=9A=94=C2=B7?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=95=ED=99=95=EB=8F=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentService.like/unlike가 삭제 여부를 확인하지 않아 소프트 삭제된 댓글에도 좋아요가 계속 쌓일 수 있었다. 삭제된 댓글이면 CommentNotFoundException을 던지도록 한다. - 좋아요·추천 카운트는 @Modifying 벌크 UPDATE로 DB 값만 갱신하고, 응답은 이미 로드해둔 stale 엔티티 값에 +1/-1 해서 계산했다. 동시 요청 시 실제 DB 값과 응답이 어긋날 수 있어, 갱신 직후 현재 값을 다시 조회해 응답한다 (CommentRepository.findLikeCountById, PostRepository.findRecommendCountById). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/repository/PostRepository.java | 3 ++ .../Backend/post/service/CommentService.java | 10 +++++-- .../Backend/post/service/PostService.java | 8 ++--- .../post/repository/PostRepositoryTest.java | 22 ++++++++++++++ .../post/service/CommentServiceTest.java | 30 +++++++++++++++++++ .../Backend/post/service/PostServiceTest.java | 2 ++ 6 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index ef3b550..3e09e46 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -33,6 +33,9 @@ public interface PostRepository extends JpaRepository { @Query("UPDATE Post p SET p.recommendCount = p.recommendCount - 1 WHERE p.id = :id") void decrementRecommendCount(@Param("id") Long id); + @Query("SELECT p.recommendCount FROM Post p WHERE p.id = :id") + Long findRecommendCountById(@Param("id") Long id); + @Modifying @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id") void incrementCommentCount(@Param("id") Long id); diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java index 0b877a5..31a4138 100644 --- a/src/main/java/com/Coming/Backend/post/service/CommentService.java +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -136,6 +136,9 @@ public void delete(Long userId, Long commentId) { @Transactional public CommentLikeCountResponse like(Long userId, Long commentId) { Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (comment.isDeleted()) { + throw new CommentNotFoundException(); + } if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) { throw new AlreadyLikedException(); } @@ -148,7 +151,7 @@ public CommentLikeCountResponse like(Long userId, Long commentId) { throw new AlreadyLikedException(); } commentRepository.incrementLikeCount(commentId); - return new CommentLikeCountResponse(comment.getLikeCount() + 1); + return new CommentLikeCountResponse(commentRepository.findLikeCountById(commentId)); } /** @@ -157,11 +160,14 @@ public CommentLikeCountResponse like(Long userId, Long commentId) { @Transactional public CommentLikeCountResponse unlike(Long userId, Long commentId) { Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + if (comment.isDeleted()) { + throw new CommentNotFoundException(); + } CommentLike like = commentLikeRepository.findByUserIdAndCommentId(userId, commentId) .orElseThrow(NotLikedException::new); commentLikeRepository.delete(like); commentRepository.decrementLikeCount(commentId); - return new CommentLikeCountResponse(comment.getLikeCount() - 1); + return new CommentLikeCountResponse(commentRepository.findLikeCountById(commentId)); } private CommentResponse toResponse(Comment comment, List replies, Long userId, diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 63ab86c..07c25c9 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -293,7 +293,7 @@ public void delete(Long userId, Long id) { */ @Transactional public RecommendCountResponse recommend(Long userId, Long id) { - Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + postRepository.findById(id).orElseThrow(PostNotFoundException::new); if (postRecommendRepository.existsByUserIdAndPostId(userId, id)) { throw new AlreadyRecommendedException(); } @@ -306,7 +306,7 @@ public RecommendCountResponse recommend(Long userId, Long id) { throw new AlreadyRecommendedException(); } postRepository.incrementRecommendCount(id); - return new RecommendCountResponse(post.getRecommendCount() + 1); + return new RecommendCountResponse(postRepository.findRecommendCountById(id)); } /** @@ -314,12 +314,12 @@ public RecommendCountResponse recommend(Long userId, Long id) { */ @Transactional public RecommendCountResponse unrecommend(Long userId, Long id) { - Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + postRepository.findById(id).orElseThrow(PostNotFoundException::new); PostRecommend recommend = postRecommendRepository.findByUserIdAndPostId(userId, id) .orElseThrow(NotRecommendedException::new); postRecommendRepository.delete(recommend); postRepository.decrementRecommendCount(id); - return new RecommendCountResponse(post.getRecommendCount() - 1); + return new RecommendCountResponse(postRepository.findRecommendCountById(id)); } private void validateContentTextLength(String contentText) { diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index c37b2e1..43e286c 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -110,6 +110,28 @@ void should_return_empty_page_when_no_post_matches_search_query() { assertThat(result.getContent()).isEmpty(); } + @Test + void should_return_current_recommend_count_when_finding_recommend_count_by_id() { + // given + Post post = Post.builder() + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title("제목") + .content("{\"type\":\"doc\"}") + .contentText("내용") + .recommendCount(5L) + .viewCount(0L) + .commentCount(0L) + .build(); + Post saved = postRepository.save(post); + + // when + Long recommendCount = postRepository.findRecommendCountById(saved.getId()); + + // then + assertThat(recommendCount).isEqualTo(5L); + } + @Test void should_return_posts_ordered_by_created_at_desc_when_multiple_posts_match() throws InterruptedException { // given diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java index 4ebf64f..240a29c 100644 --- a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -353,12 +353,27 @@ void should_throw_already_liked_exception_when_user_already_liked_comment() { verify(commentRepository, never()).incrementLikeCount(any()); } + @Test + void should_throw_comment_not_found_exception_when_liking_deleted_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제된 댓글", 5L, true); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.like(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + verify(commentLikeRepository, never()).save(any(CommentLike.class)); + verify(commentRepository, never()).incrementLikeCount(any()); + } + @Test void should_increment_like_count_and_return_incremented_count_when_user_has_not_liked_comment() { // given Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 5L, false); given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); given(commentLikeRepository.existsByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(false); + given(commentRepository.findLikeCountById(COMMENT_ID)).willReturn(6L); // when CommentLikeCountResponse response = commentService.like(OTHER_USER_ID, COMMENT_ID); @@ -399,6 +414,20 @@ void should_throw_not_liked_exception_when_user_has_not_liked_comment() { verify(commentRepository, never()).decrementLikeCount(any()); } + @Test + void should_throw_comment_not_found_exception_when_unliking_deleted_comment() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "삭제된 댓글", 5L, true); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when & then + assertThatThrownBy(() -> commentService.unlike(OTHER_USER_ID, COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + verify(commentLikeRepository, never()).delete(any(CommentLike.class)); + verify(commentRepository, never()).decrementLikeCount(any()); + } + @Test void should_decrement_like_count_and_return_decremented_count_when_user_has_liked_comment() { // given @@ -406,6 +435,7 @@ void should_decrement_like_count_and_return_decremented_count_when_user_has_like CommentLike like = CommentLike.builder().userId(OTHER_USER_ID).commentId(COMMENT_ID).build(); given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); given(commentLikeRepository.findByUserIdAndCommentId(OTHER_USER_ID, COMMENT_ID)).willReturn(Optional.of(like)); + given(commentRepository.findLikeCountById(COMMENT_ID)).willReturn(4L); // when CommentLikeCountResponse response = commentService.unlike(OTHER_USER_ID, COMMENT_ID); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index ae0282a..44a5beb 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -928,6 +928,7 @@ void should_save_recommend_and_return_incremented_count_when_user_has_not_recomm .build(); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); given(postRecommendRepository.existsByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(false); + given(postRepository.findRecommendCountById(POST_ID)).willReturn(4L); // when RecommendCountResponse response = postService.recommend(OTHER_USER_ID, POST_ID); @@ -983,6 +984,7 @@ void should_delete_recommend_and_return_decremented_count_when_user_has_recommen .build(); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); given(postRecommendRepository.findByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(Optional.of(recommend)); + given(postRepository.findRecommendCountById(POST_ID)).willReturn(2L); // when RecommendCountResponse response = postService.unrecommend(OTHER_USER_ID, POST_ID); From 6ed95d57a5a7ad1da91667e77a1efc4d015754c6 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:25:23 +0900 Subject: [PATCH 27/54] =?UTF-8?q?[fix]=20SecurityConfig=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EA=B2=80=EC=83=89=20API=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/search(통합 검색)가 permitAll 목록에 없어 비로그인 사용자가 401을 받고 있었다. 게시글 목록·상세 조회가 공개인 것과 일관되게 permitAll에 추가한다. 겸사겸사 이미 "/api/posts/**"에 포함되는 "/api/posts" 중복 나열도 정리한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../java/com/Coming/Backend/common/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index 75c97d7..e536576 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -121,7 +121,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/calendar", "/api/mentions/search", "/api/posts/**", - "/api/posts" + "/api/search" ).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") From 7e593315ac73946639a459853ef09e9d5a261553 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:25:30 +0900 Subject: [PATCH 28/54] =?UTF-8?q?[chore]=20CI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20GITHUB=5FTOKEN=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permissions: 선언이 없어 PR에서 체크아웃한 코드가 조직/저장소 기본 토큰 권한(쓰기 권한일 수 있음)으로 실행되고 있었다. contents: read로 최소 권한을 명시한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a40787f..a16f8a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: pull_request: branches: [main, develop] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest From c7fda7017019c95d99187f1e5231172b9300d9cb Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:25:43 +0900 Subject: [PATCH 29/54] =?UTF-8?q?[fix]=20Tiptap=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EC=B6=9C=20=EC=8B=9C=20=EB=B8=94=EB=A1=9D?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=EC=97=90=EC=84=9C=EB=A7=8C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=EC=9E=90=20=EC=82=BD=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 로직은 텍스트 노드가 나올 때마다 무조건 공백을 끼워 넣어, 같은 문단 안에서 굵게 등 서식으로 나뉜 인접 텍스트("안녕"+"하세요")까지 "안녕 하세요"로 바꿔버렸다. 이는 검색용 content_text와 1만자 글자수 검증 결과를 원문과 다르게 왜곡시킨다. 문단 등 블록(텍스트가 아닌 컨테이너) 경계에서만 구분자를 넣도록 순회 로직을 바꾸고, 관련 테스트를 갱신한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/util/TiptapTextExtractor.java | 18 ++++++----- .../post/util/TiptapTextExtractorTest.java | 31 +++++++++++++++---- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java index 31ef2b5..3455b01 100644 --- a/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java +++ b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java @@ -9,8 +9,9 @@ private TiptapTextExtractor() { } /** - * Tiptap 문서(JSON을 역직렬화한 Map/List 트리)에서 텍스트 노드만 추출해 공백으로 이어붙인 - * 검색용 텍스트를 반환한다. 멘션 등 텍스트 노드가 아닌 노드는 무시한다. + * Tiptap 문서(JSON을 역직렬화한 Map/List 트리)에서 텍스트 노드만 추출해 검색용 텍스트를 반환한다. + * 같은 문단 안에서 서식(굵게 등)으로 나뉜 인접 텍스트 노드는 원문 그대로 붙여 쓰고, + * 문단 등 블록 경계에서만 공백을 넣는다. 멘션 등 텍스트 노드가 아닌 노드는 무시한다. */ public static String extract(Object content) { StringBuilder builder = new StringBuilder(); @@ -26,14 +27,17 @@ private static void collect(Object node, StringBuilder builder) { if (!(node instanceof Map map)) { return; } - Object text = map.get("text"); - if (isTextNode(map) && text instanceof String textValue) { - if (!builder.isEmpty()) { - builder.append(' '); + if (isTextNode(map)) { + if (map.get("text") instanceof String textValue) { + builder.append(textValue); } - builder.append(textValue); + return; } + int lengthBeforeBlock = builder.length(); collect(map.get("content"), builder); + if (builder.length() > lengthBeforeBlock) { + builder.append(' '); + } } private static boolean isTextNode(Map node) { diff --git a/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java index 9dd2d41..8b95e46 100644 --- a/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java +++ b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java @@ -28,14 +28,33 @@ void should_extract_single_text_node() { } @Test - void should_join_multiple_text_nodes_with_space() { + void should_concatenate_adjacent_text_nodes_without_space_within_same_paragraph() { + // given: 굵게 등 서식으로 나뉜 인접 텍스트 노드("안녕"+"하세요")는 원문 그대로 붙여야 한다. + Map content = Map.of( + "type", "doc", + "content", List.of( + Map.of("type", "paragraph", "content", List.of( + Map.of("type", "text", "text", "안녕"), + Map.of("type", "text", "text", "하세요") + )) + ) + ); + + // when + String result = TiptapTextExtractor.extract(content); + + // then + assertThat(result).isEqualTo("안녕하세요"); + } + + @Test + void should_join_separate_paragraphs_with_space() { // given Map content = Map.of( "type", "doc", "content", List.of( Map.of("type", "paragraph", "content", List.of( - Map.of("type", "text", "text", "첫 문단"), - Map.of("type", "text", "text", "이어지는 텍스트") + Map.of("type", "text", "text", "첫 문단") )), Map.of("type", "paragraph", "content", List.of( Map.of("type", "text", "text", "둘째 문단") @@ -47,17 +66,17 @@ void should_join_multiple_text_nodes_with_space() { String result = TiptapTextExtractor.extract(content); // then - assertThat(result).isEqualTo("첫 문단 이어지는 텍스트 둘째 문단"); + assertThat(result).isEqualTo("첫 문단 둘째 문단"); } @Test void should_ignore_non_text_nodes_such_as_mention() { - // given + // given: 실제 작성 내용에 포함된 공백은 텍스트 노드 자체에 들어있으므로 그대로 보존한다. Map content = Map.of( "type", "doc", "content", List.of( Map.of("type", "paragraph", "content", List.of( - Map.of("type", "text", "text", "공연"), + Map.of("type", "text", "text", "공연 "), Map.of("type", "mention", "attrs", Map.of("entityType", "CONCERT", "entityId", 1)), Map.of("type", "text", "text", "다녀왔어요") )) From c9ed1c6077bd4a70295f0245f224d8b64d2dcb1b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 14 Sep 2026 17:54:06 +0900 Subject: [PATCH 30/54] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=C2=B7=EC=B6=94=EC=B2=9C=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20=EC=8B=9C=20CodeRabbit=20=EC=A7=80=EC=A0=81=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 삭제 시 댓글 좋아요를 postId 서브쿼리로 일괄 삭제해 IN 파라미터 무제한 확장 방지 - 추천 취소를 postId+userId 조건부 delete로 변경해 동시 취소 시 StaleStateException 대신 NotRecommendedException 반환 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../repository/CommentLikeRepository.java | 5 ++++ .../repository/PostRecommendRepository.java | 3 +- .../Backend/post/service/PostService.java | 12 ++++---- .../Backend/post/service/PostServiceTest.java | 30 +++---------------- 4 files changed, 15 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java index 4b80c7a..b476773 100644 --- a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -18,4 +19,8 @@ public interface CommentLikeRepository extends JpaRepository List findLikedCommentIds(@Param("userId") Long userId, @Param("commentIds") Collection commentIds); void deleteByCommentIdIn(Collection commentIds); + + @Modifying + @Query("DELETE FROM CommentLike cl WHERE cl.commentId IN (SELECT c.id FROM Comment c WHERE c.postId = :postId)") + void deleteByCommentPostId(@Param("postId") Long postId); } diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java index 25f17ea..8b833e0 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java @@ -1,14 +1,13 @@ package com.Coming.Backend.post.repository; import com.Coming.Backend.post.entity.PostRecommend; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PostRecommendRepository extends JpaRepository { boolean existsByUserIdAndPostId(Long userId, Long postId); - Optional findByUserIdAndPostId(Long userId, Long postId); + long deleteByUserIdAndPostId(Long userId, Long postId); void deleteByPostId(Long postId); } diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 07c25c9..b8c9bc9 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -278,10 +278,7 @@ public void delete(Long userId, Long id) { if (!post.isAuthoredBy(userId)) { throw new PostForbiddenException(); } - List commentIds = commentRepository.findIdsByPostId(id); - if (!commentIds.isEmpty()) { - commentLikeRepository.deleteByCommentIdIn(commentIds); - } + commentLikeRepository.deleteByCommentPostId(id); commentRepository.deleteByPostId(id); postRecommendRepository.deleteByPostId(id); postEntityTagRepository.deleteByPostId(id); @@ -315,9 +312,10 @@ public RecommendCountResponse recommend(Long userId, Long id) { @Transactional public RecommendCountResponse unrecommend(Long userId, Long id) { postRepository.findById(id).orElseThrow(PostNotFoundException::new); - PostRecommend recommend = postRecommendRepository.findByUserIdAndPostId(userId, id) - .orElseThrow(NotRecommendedException::new); - postRecommendRepository.delete(recommend); + long deletedCount = postRecommendRepository.deleteByUserIdAndPostId(userId, id); + if (deletedCount == 0) { + throw new NotRecommendedException(); + } postRepository.decrementRecommendCount(id); return new RecommendCountResponse(postRepository.findRecommendCountById(id)); } diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 44a5beb..fa25fa8 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -829,7 +829,6 @@ void should_delete_post_and_entity_tags_when_author_deletes() { // given Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - given(commentRepository.findIdsByPostId(POST_ID)).willReturn(List.of()); // when postService.delete(AUTHOR_ID, POST_ID); @@ -843,34 +842,17 @@ void should_delete_post_and_entity_tags_when_author_deletes() { void should_delete_comments_comment_likes_and_recommends_when_author_deletes_post() { // given Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); - List commentIds = List.of(1L, 2L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - given(commentRepository.findIdsByPostId(POST_ID)).willReturn(commentIds); // when postService.delete(AUTHOR_ID, POST_ID); // then - verify(commentLikeRepository).deleteByCommentIdIn(commentIds); + verify(commentLikeRepository).deleteByCommentPostId(POST_ID); verify(commentRepository).deleteByPostId(POST_ID); verify(postRecommendRepository).deleteByPostId(POST_ID); } - @Test - void should_not_call_comment_like_delete_when_post_has_no_comments() { - // given - Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); - given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - given(commentRepository.findIdsByPostId(POST_ID)).willReturn(List.of()); - - // when - postService.delete(AUTHOR_ID, POST_ID); - - // then - verify(commentLikeRepository, never()).deleteByCommentIdIn(any()); - verify(commentRepository).deleteByPostId(POST_ID); - } - // ------------------------------------------------------------------------- // recommend // ------------------------------------------------------------------------- @@ -959,7 +941,7 @@ void should_throw_not_recommended_exception_when_user_has_not_recommended_post() // given Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - given(postRecommendRepository.findByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(Optional.empty()); + given(postRecommendRepository.deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(0L); // when & then assertThatThrownBy(() -> postService.unrecommend(OTHER_USER_ID, POST_ID)) @@ -978,12 +960,8 @@ void should_delete_recommend_and_return_decremented_count_when_user_has_recommen .title("제목") .recommendCount(3L) .build(); - PostRecommend recommend = PostRecommend.builder() - .userId(OTHER_USER_ID) - .postId(POST_ID) - .build(); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); - given(postRecommendRepository.findByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(Optional.of(recommend)); + given(postRecommendRepository.deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(1L); given(postRepository.findRecommendCountById(POST_ID)).willReturn(2L); // when @@ -991,7 +969,7 @@ void should_delete_recommend_and_return_decremented_count_when_user_has_recommen // then assertThat(response.recommendCount()).isEqualTo(2L); - verify(postRecommendRepository).delete(recommend); + verify(postRecommendRepository).deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID); verify(postRepository).decrementRecommendCount(POST_ID); } } From 6c01cb077ce209048bafbd7bb361837739ac2cc2 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 16:11:28 +0900 Subject: [PATCH 31/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=B5=9C=EC=86=8C=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trim 후 2자 미만이면 InvalidInputException을 던지도록 PostService.search()에 검증 추가. 엔티티 조회 API(/api/entities/**)를 SecurityConfig permitAll에 추가. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/config/SecurityConfig.java | 3 ++- .../post/controller/SearchController.java | 2 +- .../Backend/post/service/PostService.java | 10 +++++++++- .../Backend/post/service/PostServiceTest.java | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index e536576..8e27d57 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -121,7 +121,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/calendar", "/api/mentions/search", "/api/posts/**", - "/api/search" + "/api/search", + "/api/entities/**" ).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") diff --git a/src/main/java/com/Coming/Backend/post/controller/SearchController.java b/src/main/java/com/Coming/Backend/post/controller/SearchController.java index a58e9cf..951a798 100644 --- a/src/main/java/com/Coming/Backend/post/controller/SearchController.java +++ b/src/main/java/com/Coming/Backend/post/controller/SearchController.java @@ -27,7 +27,7 @@ public class SearchController { private final PostService postService; @Operation(summary = "게시글 통합 검색") - @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백)") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 trim 후 2자 미만)") @GetMapping public ResponseEntity> search( @RequestParam @NotBlank String q, diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index b8c9bc9..4b7076e 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -75,6 +75,8 @@ public class PostService { */ private static final int MAX_CONTENT_LENGTH = 50000; + private static final int MIN_SEARCH_QUERY_LENGTH = 2; + /** * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 @@ -231,10 +233,16 @@ public List getTrendingTags(int days, int limit) { /** * 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정. + * + * @param q trim 후 2자 미만이면 InvalidInputException. */ public PageResponse search(String q, int page, int size) { + String trimmedQ = q.trim(); + if (trimmedQ.length() < MIN_SEARCH_QUERY_LENGTH) { + throw new InvalidInputException(); + } Pageable pageable = PageRequest.of(page, size); - String likeQ = "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; + String likeQ = "%" + escapeLikeWildcards(trimmedQ.toLowerCase(Locale.ROOT)) + "%"; Page result = postRepository.searchPosts(likeQ, pageable); List content = toSummaryResponses(result.getContent()); return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index fa25fa8..50c0aec 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -659,6 +659,24 @@ void should_escape_like_wildcards_when_search_query_contains_percent_and_undersc verify(postRepository).searchPosts("%50\\%\\_off%", pageable); } + @Test + void should_throw_invalid_input_exception_when_search_query_length_is_less_than_two() { + // when & then + assertThatThrownBy(() -> postService.search("a", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).searchPosts(any(), any()); + } + + @Test + void should_throw_invalid_input_exception_when_trimmed_search_query_length_is_less_than_two() { + // when & then + assertThatThrownBy(() -> postService.search(" a ", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).searchPosts(any(), any()); + } + // ------------------------------------------------------------------------- // update // ------------------------------------------------------------------------- From dd2ed5d6831b2d04add330af28a5898dcd338a30 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 16:37:44 +0900 Subject: [PATCH 32/54] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89=20API=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 무한스크롤 전환에 맞춰 GET /api/mentions/search에 page 파라미터를 추가하고 limit 기본값을 10 -> 20으로 변경. 응답을 프로젝트 표준 PageResponse ({content, page, size, totalElements, totalPages}) 형식으로 변경. - ConcertRepository.searchByTitleForMention: List -> Page 반환, countQuery 추가, startDate DESC + id DESC tie-breaker로 페이지 간 정렬 안정성 확보 - MentionService: ARTIST/RELEASE 검색에 id 오름차순 정렬을 적용해 동일한 이유로 안정성 확보 - MentionController/Service 테스트를 새 시그니처에 맞게 갱신, 페이지 파라미터 전달과 정렬 적용을 검증하는 케이스 추가 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../concert/repository/ConcertRepository.java | 5 +- .../post/controller/MentionController.java | 14 ++-- .../Backend/post/service/MentionService.java | 45 ++++++------ .../controller/MentionControllerTest.java | 24 ++++--- .../post/service/MentionServiceTest.java | 69 +++++++++++++++---- 5 files changed, 102 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java index af24102..559824b 100644 --- a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java +++ b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java @@ -35,8 +35,9 @@ public interface ConcertRepository extends JpaRepository { @Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id") void incrementViewCount(@Param("id") Long id); - @Query("SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC") - List searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable); + @Query(value = "SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC, c.id DESC", + countQuery = "SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q") + Page searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable); @Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC") List findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection hidden); diff --git a/src/main/java/com/Coming/Backend/post/controller/MentionController.java b/src/main/java/com/Coming/Backend/post/controller/MentionController.java index 68ee3b6..de08232 100644 --- a/src/main/java/com/Coming/Backend/post/controller/MentionController.java +++ b/src/main/java/com/Coming/Backend/post/controller/MentionController.java @@ -1,5 +1,6 @@ package com.Coming.Backend.post.controller; +import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.service.MentionService; @@ -17,8 +18,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @Tag(name = "Post") @Validated @RestController @@ -28,13 +27,14 @@ public class MentionController { private final MentionService mentionService; - @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성)") - @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 limit 범위 초과)") + @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성, 무한 스크롤)") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백, page 음수 또는 limit 범위 초과)") @GetMapping("/search") - public ResponseEntity> search( + public ResponseEntity> search( @RequestParam EntityType type, @RequestParam @NotBlank String q, - @RequestParam(defaultValue = "10") @Min(1) @Max(20) int limit) { - return ResponseEntity.ok(mentionService.search(type, q, limit)); + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(20) int limit) { + return ResponseEntity.ok(mentionService.search(type, q, page, limit)); } } diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index 131b844..b91e4f2 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -2,6 +2,8 @@ import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.concert.entity.Concert; import com.Coming.Backend.concert.entity.ConcertStatus; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.post.dto.EntityCardResponse; @@ -9,8 +11,10 @@ import com.Coming.Backend.release.entity.ReleaseGroup; import com.Coming.Backend.release.repository.ReleaseGroupRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,40 +39,37 @@ public class MentionService { private final EntityLookupService entityLookupService; /** - * 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 최대 limit건 검색한다. + * 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 페이지 단위로 검색한다. + * 무한 스크롤 조회를 위해 id를 tie-breaker로 사용해 페이지 간 정렬을 안정적으로 유지한다. */ - public List search(EntityType type, String q, int limit) { - Pageable pageable = PageRequest.of(0, limit); + public PageResponse search(EntityType type, String q, int page, int size) { return switch (type) { - case CONCERT -> searchConcerts(q, pageable); - case ARTIST -> searchArtists(q, pageable); - case RELEASE -> searchReleases(q, pageable); + case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); + case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case RELEASE -> searchReleases(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); }; } - private List searchConcerts(String q, Pageable pageable) { + private PageResponse searchConcerts(String q, Pageable pageable) { String likeQ = toLikePattern(q); - return concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable).stream() - .map(entityLookupService::toCard) - .toList(); + Page concerts = concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable); + return PageResponse.from(concerts.map(entityLookupService::toCard)); } - private List searchArtists(String q, Pageable pageable) { - return artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable).stream() - .map(entityLookupService::toCard) - .toList(); + private PageResponse searchArtists(String q, Pageable pageable) { + Page artists = artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable); + return PageResponse.from(artists.map(entityLookupService::toCard)); } - private List searchReleases(String q, Pageable pageable) { - List releases = releaseGroupRepository - .searchReleases(null, null, null, toLikePattern(q), pageable) - .getContent(); + private PageResponse searchReleases(String q, Pageable pageable) { + Page releases = releaseGroupRepository + .searchReleases(null, null, null, toLikePattern(q), pageable); Map artistNames = artistRepository.findAllById( - releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + releases.getContent().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); - return releases.stream() - .map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId()))) - .toList(); + return PageResponse.from( + releases.map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId()))) + ); } private String toLikePattern(String q) { diff --git a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java index 154438f..991ebf5 100644 --- a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java @@ -1,6 +1,5 @@ package com.Coming.Backend.post.controller; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -10,6 +9,7 @@ import com.Coming.Backend.common.discord.NoOpDiscordNotifier; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.service.MentionService; @@ -22,6 +22,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -55,7 +56,8 @@ void should_return_200_with_entity_card_list_when_valid_request_given() throws E // given EntityCardResponse card = new EntityCardResponse( EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null); - given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(10))).willReturn(List.of(card)); + given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(0), eq(20))) + .willReturn(PageResponse.from(new PageImpl<>(List.of(card)))); // when & then mockMvc.perform(get("/api/mentions/search") @@ -63,11 +65,11 @@ void should_return_200_with_entity_card_list_when_valid_request_given() throws E .param("q", "아이유") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].type").value("CONCERT")) - .andExpect(jsonPath("$[0].id").value(CONCERT_ID)) - .andExpect(jsonPath("$[0].title").value("아이유 콘서트")); + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].type").value("CONCERT")) + .andExpect(jsonPath("$.content[0].id").value(CONCERT_ID)) + .andExpect(jsonPath("$.content[0].title").value("아이유 콘서트")); } @Test @@ -96,8 +98,8 @@ void should_return_400_when_type_is_invalid_value() throws Exception { @Test void should_return_200_with_empty_array_when_no_search_result_found() throws Exception { // given - given(mentionService.search(eq(EntityType.ARTIST), eq("없는아티스트"), any(Integer.class))) - .willReturn(List.of()); + given(mentionService.search(eq(EntityType.ARTIST), eq("없는아티스트"), eq(0), eq(20))) + .willReturn(PageResponse.from(new PageImpl<>(List.of()))); // when & then mockMvc.perform(get("/api/mentions/search") @@ -105,7 +107,7 @@ void should_return_200_with_empty_array_when_no_search_result_found() throws Exc .param("q", "없는아티스트") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(0)); + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(0)); } } diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index b31e85a..96ac89a 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -8,6 +8,7 @@ import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.concert.entity.Concert; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.post.dto.EntityCardResponse; @@ -20,12 +21,15 @@ 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.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; @ExtendWith(MockitoExtension.class) class MentionServiceTest { @@ -45,7 +49,8 @@ class MentionServiceTest { @Mock private EntityLookupService entityLookupService; - private static final int LIMIT = 10; + private static final int PAGE = 0; + private static final int SIZE = 10; private static final Long CONCERT_ID = 1L; private static final Long ARTIST_ID = 10L; private static final Long RELEASE_ID = 100L; @@ -57,16 +62,21 @@ void should_return_concert_cards_when_type_is_CONCERT() { String q = "아이유"; Concert concert = Concert.builder().id(CONCERT_ID).title("아이유 콘서트").build(); EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null); + Page concertPage = new PageImpl<>(List.of(concert), PageRequest.of(PAGE, SIZE), 1); given(concertRepository.searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class))) - .willReturn(List.of(concert)); + .willReturn(concertPage); given(entityLookupService.toCard(concert)).willReturn(card); // when - List result = mentionService.search(EntityType.CONCERT, q, LIMIT); + PageResponse result = mentionService.search(EntityType.CONCERT, q, PAGE, SIZE); // then verify(concertRepository).searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class)); - assertThat(result).containsExactly(card); + assertThat(result.content()).containsExactly(card); + assertThat(result.page()).isEqualTo(PAGE); + assertThat(result.size()).isEqualTo(SIZE); + assertThat(result.totalElements()).isEqualTo(1L); + assertThat(result.totalPages()).isEqualTo(1); } @Test @@ -80,11 +90,11 @@ void should_call_artist_repository_with_raw_query_when_type_is_ARTIST() { given(entityLookupService.toCard(artist)).willReturn(card); // when - List result = mentionService.search(EntityType.ARTIST, q, LIMIT); + PageResponse result = mentionService.search(EntityType.ARTIST, q, PAGE, SIZE); // then verify(artistRepository).findByNameOrAliasContainingIgnoreCase(eq("아이유"), any(Pageable.class)); - assertThat(result).containsExactly(card); + assertThat(result.content()).containsExactly(card); } @Test @@ -101,36 +111,69 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { given(entityLookupService.toCard(release, "IU")).willReturn(card); // when - List result = mentionService.search(EntityType.RELEASE, q, LIMIT); + PageResponse result = mentionService.search(EntityType.RELEASE, q, PAGE, SIZE); // then verify(artistRepository).findAllById(eq(Set.of(RELEASE_ARTIST_ID))); - assertThat(result).containsExactly(card); + assertThat(result.content()).containsExactly(card); } @Test void should_return_empty_list_when_no_search_result_found() { // given given(concertRepository.searchByTitleForMention(any(), eq("%없는공연%"), any(Pageable.class))) - .willReturn(List.of()); + .willReturn(new PageImpl<>(List.of())); // when - List result = mentionService.search(EntityType.CONCERT, "없는공연", LIMIT); + PageResponse result = mentionService.search(EntityType.CONCERT, "없는공연", PAGE, SIZE); // then - assertThat(result).isEmpty(); + assertThat(result.content()).isEmpty(); } @Test void should_escape_like_wildcards_when_query_contains_percent_and_underscore() { // given given(concertRepository.searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class))) - .willReturn(List.of()); + .willReturn(new PageImpl<>(List.of())); // when - mentionService.search(EntityType.CONCERT, "50%_off", LIMIT); + mentionService.search(EntityType.CONCERT, "50%_off", PAGE, SIZE); // then verify(concertRepository).searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class)); } + + @Test + void should_build_pageable_with_requested_page_number_when_page_is_not_zero() { + // given + int requestedPage = 1; + int requestedSize = 5; + given(concertRepository.searchByTitleForMention(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.CONCERT, "아이유", requestedPage, requestedSize); + + // then + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(concertRepository).searchByTitleForMention(any(), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(requestedPage); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(requestedSize); + } + + @Test + void should_request_id_ascending_sort_when_type_is_ARTIST() { + // given + given(artistRepository.findByNameOrAliasContainingIgnoreCase(any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.ARTIST, "아이유", PAGE, SIZE); + + // then + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(artistRepository).findByNameOrAliasContainingIgnoreCase(any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.ASC, "id")); + } } From 45e65135a3162e124c322b0951aa035bdc036a3c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:06:18 +0900 Subject: [PATCH 33/54] =?UTF-8?q?[feat]=20EntityCardResponse=EC=97=90=20re?= =?UTF-8?q?leaseGroupId=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랙 멘션 도입을 위한 준비 작업. 트랙이 속한 앨범 id를 응답에 실어 FE가 앨범 상세 페이지로 링크를 구성할 수 있게 한다. TRACK 외 타입은 당분간 null을 반환한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/post/dto/EntityCardResponse.java | 3 ++- .../Coming/Backend/post/service/EntityLookupService.java | 6 +++--- .../Backend/post/controller/MentionControllerTest.java | 2 +- .../com/Coming/Backend/post/service/MentionServiceTest.java | 6 +++--- .../com/Coming/Backend/post/service/PostServiceTest.java | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java index 11e5a0b..8f743fd 100644 --- a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java @@ -7,6 +7,7 @@ public record EntityCardResponse( Long id, String title, String subtitle, - String thumbnailUrl + String thumbnailUrl, + Long releaseGroupId ) { } diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java index b65d850..08c36bc 100644 --- a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -61,15 +61,15 @@ private Set idsOf(Collection keys, EntityType type) { EntityCardResponse toCard(Concert concert) { String subtitle = concert.getStartDate() + " · " + concert.getVenueName(); - return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl()); + return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl(), null); } EntityCardResponse toCard(Artist artist) { - return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl()); + return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl(), null); } EntityCardResponse toCard(ReleaseGroup release, String artistName) { - return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl()); + return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl(), null); } public record EntityKey(EntityType type, Long id) { diff --git a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java index 991ebf5..9977e2a 100644 --- a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java @@ -55,7 +55,7 @@ void setUp() { void should_return_200_with_entity_card_list_when_valid_request_given() throws Exception { // given EntityCardResponse card = new EntityCardResponse( - EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null); + EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null, null); given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(0), eq(20))) .willReturn(PageResponse.from(new PageImpl<>(List.of(card)))); diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index 96ac89a..ce77aab 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -61,7 +61,7 @@ void should_return_concert_cards_when_type_is_CONCERT() { // given String q = "아이유"; Concert concert = Concert.builder().id(CONCERT_ID).title("아이유 콘서트").build(); - EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null); + EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null, null); Page concertPage = new PageImpl<>(List.of(concert), PageRequest.of(PAGE, SIZE), 1); given(concertRepository.searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class))) .willReturn(concertPage); @@ -85,7 +85,7 @@ void should_call_artist_repository_with_raw_query_when_type_is_ARTIST() { String q = "아이유"; Artist artist = Artist.builder().id(ARTIST_ID).name("아이유").build(); Page page = new PageImpl<>(List.of(artist)); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, ARTIST_ID, "아이유", null, null); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, ARTIST_ID, "아이유", null, null, null); given(artistRepository.findByNameOrAliasContainingIgnoreCase(eq(q), any(Pageable.class))).willReturn(page); given(entityLookupService.toCard(artist)).willReturn(card); @@ -104,7 +104,7 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { ReleaseGroup release = ReleaseGroup.builder().id(RELEASE_ID).artistId(RELEASE_ARTIST_ID).title("LILAC").build(); Artist releaseArtist = Artist.builder().id(RELEASE_ARTIST_ID).name("IU").build(); Page page = new PageImpl<>(List.of(release)); - EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null); + EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null, null); given(releaseGroupRepository.searchReleases(eq(null), eq(null), eq(null), eq("%lilac%"), any(Pageable.class))) .willReturn(page); given(artistRepository.findAllById(eq(Set.of(RELEASE_ARTIST_ID)))).willReturn(List.of(releaseArtist)); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 50c0aec..2bd4e40 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -407,7 +407,7 @@ void should_map_entity_tags_using_entity_lookup_service_when_post_found() { .entityType(EntityType.ARTIST) .entityId(1L) .build(); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); @@ -559,7 +559,7 @@ void should_return_trending_tags_when_entity_tags_found() { Pageable pageable = PageRequest.of(0, 10); EntityTagCount count = new EntityTagCount(EntityType.ARTIST, 1L, 3L); EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))).willReturn(List.of(count)); given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); @@ -596,7 +596,7 @@ void should_filter_out_trending_tag_when_entity_reference_is_deleted() { EntityTagCount deletedCount = new EntityTagCount(EntityType.CONCERT, 2L, 2L); EntityLookupService.EntityKey aliveKey = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); EntityLookupService.EntityKey deletedKey = new EntityLookupService.EntityKey(EntityType.CONCERT, 2L); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))) .willReturn(List.of(aliveCount, deletedCount)); given(entityLookupService.findCards(List.of(aliveKey, deletedKey))).willReturn(Map.of(aliveKey, card)); From 2be4886ab9c83ae4de8c81fee3537d02910f97d8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:09:22 +0900 Subject: [PATCH 34/54] =?UTF-8?q?[feat]=20TrackRepository=EC=97=90=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConcertRepository.searchByTitleForMention과 동일한 패턴으로 트랙명 부분 일치 검색을 추가한다. 기존 LIKE 쿼리들과 달리 ESCAPE '\' 절을 명시해 서비스 레이어의 와일드카드 이스케이프가 실제로 동작하도록 했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../release/repository/TrackRepository.java | 8 ++ .../repository/TrackRepositoryTest.java | 89 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java diff --git a/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java b/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java index 9883bf2..004609f 100644 --- a/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java +++ b/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java @@ -1,7 +1,11 @@ package com.Coming.Backend.release.repository; import com.Coming.Backend.release.entity.Track; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -11,4 +15,8 @@ public interface TrackRepository extends JpaRepository { List findByReleaseGroupIdOrderByPosition(Long releaseGroupId); List findByReleaseGroupIdInOrderByPosition(Collection releaseGroupIds); + + @Query(value = "SELECT t FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\' ORDER BY t.id ASC", + countQuery = "SELECT COUNT(t) FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\'") + Page searchByTitleForMention(@Param("q") String q, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java b/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java new file mode 100644 index 0000000..f46dd80 --- /dev/null +++ b/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java @@ -0,0 +1,89 @@ +package com.Coming.Backend.release.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.release.entity.Track; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class TrackRepositoryTest { + + @Autowired + private TrackRepository trackRepository; + + private static final Long RELEASE_GROUP_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private Track buildTrack(String title) { + return Track.builder() + .releaseGroupId(RELEASE_GROUP_ID) + .title(title) + .position(1) + .build(); + } + + @Test + void should_return_track_when_title_partially_matches_search_query_ignoring_case() { + // given + Track track = trackRepository.save(buildTrack("Dynamite")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("DYNA"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId).contains(track.getId()); + } + + @Test + void should_exclude_track_when_title_does_not_match_search_query() { + // given + Track matching = trackRepository.save(buildTrack("Dynamite")); + Track notMatching = trackRepository.save(buildTrack("Butter")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("Dyna"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId) + .contains(matching.getId()) + .doesNotContain(notMatching.getId()); + } + + @Test + void should_return_empty_page_when_no_track_matches_search_query() { + // given + trackRepository.save(buildTrack("Dynamite")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_match_wildcard_characters_literally_when_escaped_query_given() { + // given + Track track = trackRepository.save(buildTrack("100% Ready")); + trackRepository.save(buildTrack("100 Ready")); + String escapedQuery = "%" + "100\\% ready".toLowerCase(Locale.ROOT) + "%"; + + // when + Page result = trackRepository.searchByTitleForMention(escapedQuery, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId).containsExactly(track.getId()); + } +} From e1bb5ce85a18ffbf76c307900ad4c8e1516d646c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:20:59 +0900 Subject: [PATCH 35/54] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=EC=97=90=20TRACK=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앨범(RELEASE) 단위로만 지원되던 음악 멘션에 트랙 단위를 추가한다. MentionService.searchTracks()가 트랙명으로 검색하고, EntityLookupService.toTrackCardsById()가 트랙 → 앨범 → 아티스트를 배치 조회해 카드(제목=트랙명, 부제=아티스트·앨범명, releaseGroupId= FE 링크 구성용)로 변환한다. 앨범 참조가 끊긴 트랙(FK 제약 없음)은 제목만 채운 카드로 안전하게 대체한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/dto/EntityCardResponse.java | 1 + .../Backend/post/entity/EntityType.java | 2 +- .../post/service/EntityLookupService.java | 35 ++++++++++++ .../Backend/post/service/MentionService.java | 10 ++++ .../post/service/EntityLookupServiceTest.java | 54 +++++++++++++++++++ .../post/service/MentionServiceTest.java | 25 +++++++++ 6 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java index 8f743fd..dbee09f 100644 --- a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java @@ -8,6 +8,7 @@ public record EntityCardResponse( String title, String subtitle, String thumbnailUrl, + // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null. Long releaseGroupId ) { } diff --git a/src/main/java/com/Coming/Backend/post/entity/EntityType.java b/src/main/java/com/Coming/Backend/post/entity/EntityType.java index e52d946..a6f1bac 100644 --- a/src/main/java/com/Coming/Backend/post/entity/EntityType.java +++ b/src/main/java/com/Coming/Backend/post/entity/EntityType.java @@ -1,5 +1,5 @@ package com.Coming.Backend.post.entity; public enum EntityType { - CONCERT, ARTIST, RELEASE + CONCERT, ARTIST, RELEASE, TRACK } diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java index 08c36bc..22c8575 100644 --- a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -7,7 +7,9 @@ import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -27,6 +30,7 @@ public class EntityLookupService { private final ConcertRepository concertRepository; private final ArtistRepository artistRepository; private final ReleaseGroupRepository releaseGroupRepository; + private final TrackRepository trackRepository; /** * entityType·entityId 키 목록에 대응하는 엔티티 카드 정보를 일괄 조회한다. @@ -49,9 +53,32 @@ public Map findCards(Collection keys) result.put(new EntityKey(EntityType.RELEASE, release.getId()), toCard(release, artistNames.get(release.getArtistId())))); + List tracks = trackRepository.findAllById(idsOf(keys, EntityType.TRACK)); + toTrackCardsById(tracks).forEach((trackId, card) -> + result.put(new EntityKey(EntityType.TRACK, trackId), card)); + return result; } + /** + * 트랙 목록이 속한 앨범·아티스트 정보를 배치 조회해 트랙 id별 카드로 변환한다. + * 앨범이 삭제되어 참조가 끊긴 트랙은 제목만 채운 카드를 반환한다. + */ + Map toTrackCardsById(Collection tracks) { + Map releaseGroupsById = releaseGroupRepository.findAllById( + tracks.stream().map(Track::getReleaseGroupId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(ReleaseGroup::getId, Function.identity())); + Map artistNames = artistRepository.findAllById( + releaseGroupsById.values().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + + return tracks.stream().collect(Collectors.toMap(Track::getId, track -> { + ReleaseGroup releaseGroup = releaseGroupsById.get(track.getReleaseGroupId()); + String artistName = releaseGroup != null ? artistNames.get(releaseGroup.getArtistId()) : null; + return toCard(track, releaseGroup, artistName); + })); + } + private Set idsOf(Collection keys, EntityType type) { return keys.stream() .filter(key -> key.type() == type) @@ -72,6 +99,14 @@ EntityCardResponse toCard(ReleaseGroup release, String artistName) { return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl(), null); } + EntityCardResponse toCard(Track track, ReleaseGroup releaseGroup, String artistName) { + if (releaseGroup == null) { + return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), null, null, null); + } + String subtitle = artistName != null ? artistName + " · " + releaseGroup.getTitle() : releaseGroup.getTitle(); + return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), subtitle, releaseGroup.getCoverUrl(), releaseGroup.getId()); + } + public record EntityKey(EntityType type, Long id) { } } diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index b91e4f2..8055ef4 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -9,7 +9,9 @@ import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -36,6 +38,7 @@ public class MentionService { private final ConcertRepository concertRepository; private final ArtistRepository artistRepository; private final ReleaseGroupRepository releaseGroupRepository; + private final TrackRepository trackRepository; private final EntityLookupService entityLookupService; /** @@ -47,6 +50,7 @@ public PageResponse search(EntityType type, String q, int pa case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); case RELEASE -> searchReleases(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case TRACK -> searchTracks(q, PageRequest.of(page, size)); }; } @@ -72,6 +76,12 @@ private PageResponse searchReleases(String q, Pageable pagea ); } + private PageResponse searchTracks(String q, Pageable pageable) { + Page tracks = trackRepository.searchByTitleForMention(toLikePattern(q), pageable); + Map cardsByTrackId = entityLookupService.toTrackCardsById(tracks.getContent()); + return PageResponse.from(tracks.map(track -> cardsByTrackId.get(track.getId()))); + } + private String toLikePattern(String q) { return "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; } diff --git a/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java index b5f5fa4..4cd0701 100644 --- a/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java @@ -12,7 +12,9 @@ import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.service.EntityLookupService.EntityKey; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import java.time.LocalDate; import java.util.List; @@ -40,10 +42,14 @@ class EntityLookupServiceTest { @Mock private ReleaseGroupRepository releaseGroupRepository; + @Mock + private TrackRepository trackRepository; + private static final Long CONCERT_ID = 1L; private static final Long ARTIST_ID = 10L; private static final Long RELEASE_ID = 100L; private static final Long RELEASE_ARTIST_ID = 20L; + private static final Long TRACK_ID = 200L; private Concert buildConcert(Long id) { return Concert.builder() @@ -73,6 +79,15 @@ private ReleaseGroup buildReleaseGroup(Long id, Long artistId) { .build(); } + private Track buildTrack(Long id, Long releaseGroupId) { + return Track.builder() + .id(id) + .releaseGroupId(releaseGroupId) + .title("라일락") + .position(1) + .build(); + } + @Test void should_fill_subtitle_with_date_and_venue_and_thumbnail_with_poster_url_when_concert_key_given() { // given @@ -123,6 +138,45 @@ void should_fill_subtitle_with_joined_artist_name_and_thumbnail_with_cover_url_w assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); } + @Test + void should_fill_subtitle_with_artist_and_album_and_thumbnail_with_cover_url_when_track_key_given() { + // given + Track track = buildTrack(TRACK_ID, RELEASE_ID); + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist artist = buildArtist(RELEASE_ARTIST_ID, "IU"); + given(trackRepository.findAllById(any())).willReturn(List.of(track)); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.TRACK, TRACK_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.TRACK, TRACK_ID)); + assertThat(card.subtitle()).isEqualTo("IU · LILAC"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); + } + + @Test + void should_fill_only_title_when_track_release_group_not_found() { + // given + Track track = buildTrack(TRACK_ID, RELEASE_ID); + given(trackRepository.findAllById(any())).willReturn(List.of(track)); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of()); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.TRACK, TRACK_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.TRACK, TRACK_ID)); + assertThat(card.title()).isEqualTo("라일락"); + assertThat(card.subtitle()).isNull(); + assertThat(card.thumbnailUrl()).isNull(); + assertThat(card.releaseGroupId()).isNull(); + } + @Test void should_fill_all_three_types_when_mixed_keys_given() { // given diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index ce77aab..6d3d620 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -14,9 +14,12 @@ import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -46,6 +49,9 @@ class MentionServiceTest { @Mock private ReleaseGroupRepository releaseGroupRepository; + @Mock + private TrackRepository trackRepository; + @Mock private EntityLookupService entityLookupService; @@ -55,6 +61,7 @@ class MentionServiceTest { private static final Long ARTIST_ID = 10L; private static final Long RELEASE_ID = 100L; private static final Long RELEASE_ARTIST_ID = 20L; + private static final Long TRACK_ID = 200L; @Test void should_return_concert_cards_when_type_is_CONCERT() { @@ -118,6 +125,24 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { assertThat(result.content()).containsExactly(card); } + @Test + void should_return_track_cards_when_type_is_TRACK() { + // given + String q = "라일락"; + Track track = Track.builder().id(TRACK_ID).releaseGroupId(RELEASE_ID).title("라일락").position(1).build(); + Page trackPage = new PageImpl<>(List.of(track)); + EntityCardResponse card = new EntityCardResponse(EntityType.TRACK, TRACK_ID, "라일락", "IU · LILAC", null, RELEASE_ID); + given(trackRepository.searchByTitleForMention(eq("%라일락%"), any(Pageable.class))).willReturn(trackPage); + given(entityLookupService.toTrackCardsById(List.of(track))).willReturn(Map.of(TRACK_ID, card)); + + // when + PageResponse result = mentionService.search(EntityType.TRACK, q, PAGE, SIZE); + + // then + verify(trackRepository).searchByTitleForMention(eq("%라일락%"), any(Pageable.class)); + assertThat(result.content()).containsExactly(card); + } + @Test void should_return_empty_list_when_no_search_result_found() { // given From 28520a4714f354bcc5ec12d5fae008521422c2ef Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:21:07 +0900 Subject: [PATCH 36/54] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A0=84=EB=AC=B8=EA=B2=80=EC=83=89=EC=97=90=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=EB=AA=85=20=EB=A7=A4=EC=B9=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostRepository.searchPosts()가 ARTIST/CONCERT/RELEASE 태그된 게시글만 이름으로 찾을 수 있었다. TRACK 태그 조건을 동일한 형태로 추가해, 트랙이 멘션된 게시글도 트랙명으로 검색되게 한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/repository/PostRepository.java | 2 ++ .../post/repository/PostRepositoryTest.java | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index 3e09e46..d03b4ab 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -54,6 +54,8 @@ AND t.entityId IN (SELECT a.id FROM Artist a WHERE LOWER(a.name) LIKE :q)) AND t.entityId IN (SELECT c.id FROM Concert c WHERE LOWER(c.title) LIKE :q)) OR (t.entityType = com.Coming.Backend.post.entity.EntityType.RELEASE AND t.entityId IN (SELECT r.id FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK + AND t.entityId IN (SELECT tr.id FROM Track tr WHERE LOWER(tr.title) LIKE :q)) ) ORDER BY p.createdAt DESC """) diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 43e286c..5e9d533 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -8,6 +8,8 @@ import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.TrackRepository; import java.util.Locale; import java.util.UUID; @@ -33,6 +35,9 @@ class PostRepositoryTest { @Autowired private ArtistRepository artistRepository; + @Autowired + private TrackRepository trackRepository; + private static final Long AUTHOR_ID = 1L; private String likeQuery(String q) { @@ -98,6 +103,28 @@ void should_return_post_when_tagged_artist_name_matches_search_query() { assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); } + @Test + void should_return_post_when_tagged_track_title_matches_search_query() { + // given + Track track = trackRepository.save(Track.builder() + .releaseGroupId(1L) + .title("LILAC") + .position(1) + .build()); + Post post = postRepository.save(buildPost("이 곡 좋아요", "요즘 계속 듣는 중")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.searchPosts(likeQuery("LILAC"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + @Test void should_return_empty_page_when_no_post_matches_search_query() { // given From 24a3f0ea07eae7ad2858d5fa40c3ba93a1a777c1 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:31:50 +0900 Subject: [PATCH 37/54] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20ent?= =?UTF-8?q?ityTags=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EC=95=A8=EB=B2=94=20id=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACK 멘션 카드(EntityCardResponse)에는 releaseGroupId가 채워지지만, 게시글 상세·목록의 entityTags(PostEntityTagResponse)에는 반영되지 않아 게시글 하단 태그 칩에서 트랙 앨범으로 딥링크할 수 없었다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/dto/PostEntityTagResponse.java | 6 +++-- .../Backend/post/service/PostServiceTest.java | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java index a9bd57c..bcfdb68 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java @@ -7,9 +7,11 @@ public record PostEntityTagResponse( Long entityId, String title, String subtitle, - String thumbnailUrl + String thumbnailUrl, + // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null. + Long releaseGroupId ) { public static PostEntityTagResponse of(EntityType entityType, Long entityId, EntityCardResponse card) { - return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl()); + return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl(), card.releaseGroupId()); } } diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 2bd4e40..18cc419 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -425,6 +425,33 @@ void should_map_entity_tags_using_entity_lookup_service_when_post_found() { assertThat(response.entityTags().get(0).title()).isEqualTo("IU"); } + @Test + void should_include_release_group_id_when_entity_tag_is_track() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + PostEntityTag tag = PostEntityTag.builder() + .postId(POST_ID) + .entityType(EntityType.TRACK) + .entityId(200L) + .build(); + EntityCardResponse card = new EntityCardResponse(EntityType.TRACK, 200L, "라일락", "IU · LILAC", null, 100L); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.TRACK, 200L); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of(tag)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.entityTags()).hasSize(1); + assertThat(response.entityTags().get(0).entityType()).isEqualTo(EntityType.TRACK); + assertThat(response.entityTags().get(0).title()).isEqualTo("라일락"); + assertThat(response.entityTags().get(0).releaseGroupId()).isEqualTo(100L); + } + // ------------------------------------------------------------------------- // getList // ------------------------------------------------------------------------- From 5e4985b8a61453c097d62554fe0b3fd441448daf Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:44:09 +0900 Subject: [PATCH 38/54] =?UTF-8?q?[feat]=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B2=8C=EC=8B=9C=EA=B8=80=EC=97=90=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=99=20=ED=83=9C=EA=B7=B8=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조라, RELEASE 백링크 조회 시 해당 릴리즈에 속한 트랙이 태그된 게시글도 함께 노출되도록 findByEntityTag 확장 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/repository/PostRepository.java | 16 ++++++- .../post/repository/PostRepositoryTest.java | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index d03b4ab..2a6b258 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -40,7 +40,21 @@ public interface PostRepository extends JpaRepository { @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id") void incrementCommentCount(@Param("id") Long id); - @Query("SELECT p FROM Post p WHERE p.id IN (SELECT t.postId FROM PostEntityTag t WHERE t.entityType = :entityType AND t.entityId = :entityId)") + /** + * RELEASE 조회 시, 해당 릴리즈에 속한 트랙(Track.releaseGroupId)이 태그된 게시글도 함께 포함한다. + * 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조(`/releases/{releaseGroupId}#track-{id}`)이므로, + * 트랙을 언급한 글도 해당 앨범의 관련 게시글로 노출되어야 사용자에게 자연스럽다. + */ + @Query(""" + SELECT p FROM Post p + WHERE p.id IN ( + SELECT t.postId FROM PostEntityTag t + WHERE (t.entityType = :entityType AND t.entityId = :entityId) + OR (:entityType = com.Coming.Backend.post.entity.EntityType.RELEASE + AND t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK + AND t.entityId IN (SELECT tr.id FROM Track tr WHERE tr.releaseGroupId = :entityId)) + ) + """) Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable); @Query(""" diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 5e9d533..26ec90d 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -137,6 +137,51 @@ void should_return_empty_page_when_no_post_matches_search_query() { assertThat(result.getContent()).isEmpty(); } + @Test + void should_return_post_when_tagged_track_belongs_to_release() { + // given + Long releaseGroupId = 1L; + Track track = trackRepository.save(Track.builder() + .releaseGroupId(releaseGroupId) + .title("LILAC") + .position(1) + .build()); + Post post = postRepository.save(buildPost("이 앨범의 이 곡이 좋아요", "명곡이다")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.findByEntityTag(EntityType.RELEASE, releaseGroupId, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_not_return_post_when_tagged_track_belongs_to_other_release() { + // given + Track track = trackRepository.save(Track.builder() + .releaseGroupId(2L) + .title("다른 앨범 트랙") + .position(1) + .build()); + Post post = postRepository.save(buildPost("다른 앨범 곡 얘기", "내용")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.findByEntityTag(EntityType.RELEASE, 1L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).doesNotContain(post.getId()); + } + @Test void should_return_current_recommend_count_when_finding_recommend_count_by_id() { // given From 6a71b2fb248f9329761c27418dc4ba8b086b55e8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 16:58:16 +0900 Subject: [PATCH 39/54] =?UTF-8?q?[fix]=20=EC=9D=8C=EC=95=85=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8A=B8=EB=9E=99=EB=AA=85=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACK이 별도 멘션 타입으로 분리된 이후에도 RELEASE 멘션 검색이 내부 트랙명까지 매칭해, 검색어와 무관해 보이는 앨범이 노출되는 문제가 있었다. 멘션 전용 쿼리(searchByTitleForMention)를 추가해 릴리즈 제목만 매칭하도록 분리하고, 기존 searchReleases(트랙/아티스트명 포함 매칭)는 일반 릴리즈 목록 API에서 그대로 사용한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/service/MentionService.java | 4 +- .../repository/ReleaseGroupRepository.java | 4 + .../post/service/MentionServiceTest.java | 2 +- .../ReleaseGroupRepositoryTest.java | 88 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index 8055ef4..b97ddcc 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -49,7 +49,7 @@ public PageResponse search(EntityType type, String q, int pa return switch (type) { case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); - case RELEASE -> searchReleases(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case RELEASE -> searchReleases(q, PageRequest.of(page, size)); case TRACK -> searchTracks(q, PageRequest.of(page, size)); }; } @@ -67,7 +67,7 @@ private PageResponse searchArtists(String q, Pageable pageab private PageResponse searchReleases(String q, Pageable pageable) { Page releases = releaseGroupRepository - .searchReleases(null, null, null, toLikePattern(q), pageable); + .searchByTitleForMention(toLikePattern(q), pageable); Map artistNames = artistRepository.findAllById( releases.getContent().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); diff --git a/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java b/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java index 5cb8a56..0c7b057 100644 --- a/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java +++ b/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java @@ -65,4 +65,8 @@ Page searchReleases(@Param("artistId") Long artistId, @Param("type") String type, @Param("q") String q, Pageable pageable); + + @Query(value = "SELECT r FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\' ORDER BY r.id ASC", + countQuery = "SELECT COUNT(r) FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\'") + Page searchByTitleForMention(@Param("q") String q, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index 6d3d620..7b0b887 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -112,7 +112,7 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { Artist releaseArtist = Artist.builder().id(RELEASE_ARTIST_ID).name("IU").build(); Page page = new PageImpl<>(List.of(release)); EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null, null); - given(releaseGroupRepository.searchReleases(eq(null), eq(null), eq(null), eq("%lilac%"), any(Pageable.class))) + given(releaseGroupRepository.searchByTitleForMention(eq("%lilac%"), any(Pageable.class))) .willReturn(page); given(artistRepository.findAllById(eq(Set.of(RELEASE_ARTIST_ID)))).willReturn(List.of(releaseArtist)); given(entityLookupService.toCard(release, "IU")).willReturn(card); diff --git a/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java b/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java new file mode 100644 index 0000000..f31fabe --- /dev/null +++ b/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java @@ -0,0 +1,88 @@ +package com.Coming.Backend.release.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.release.entity.ReleaseGroup; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ReleaseGroupRepositoryTest { + + @Autowired + private ReleaseGroupRepository releaseGroupRepository; + + private static final Long ARTIST_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private ReleaseGroup buildReleaseGroup(String title) { + return ReleaseGroup.builder() + .artistId(ARTIST_ID) + .title(title) + .build(); + } + + @Test + void should_return_release_when_title_partially_matches_search_query_ignoring_case() { + // given + ReleaseGroup release = releaseGroupRepository.save(buildReleaseGroup("LILAC")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("lil"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId).contains(release.getId()); + } + + @Test + void should_exclude_release_when_title_does_not_match_search_query() { + // given + ReleaseGroup matching = releaseGroupRepository.save(buildReleaseGroup("LILAC")); + ReleaseGroup notMatching = releaseGroupRepository.save(buildReleaseGroup("Palette")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("lil"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId) + .contains(matching.getId()) + .doesNotContain(notMatching.getId()); + } + + @Test + void should_return_empty_page_when_no_release_matches_search_query() { + // given + releaseGroupRepository.save(buildReleaseGroup("LILAC")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_match_wildcard_characters_literally_when_escaped_query_given() { + // given + ReleaseGroup release = releaseGroupRepository.save(buildReleaseGroup("100% Ready")); + releaseGroupRepository.save(buildReleaseGroup("100 Ready")); + String escapedQuery = "%" + "100\\% ready".toLowerCase(Locale.ROOT) + "%"; + + // when + Page result = releaseGroupRepository.searchByTitleForMention(escapedQuery, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId).containsExactly(release.getId()); + } +} From fc6ce63df8b8fca2d13dc1156fe62f9edee359b3 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:35:17 +0900 Subject: [PATCH 40/54] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EB=93=B1=EB=A1=9D=20API=20=EB=B0=8F=20PolicyDocume?= =?UTF-8?q?nt=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: 약관 변경 이메일 고지 시스템의 기반이 되는 정책 버전 메타데이터(PolicyDocument)를 도입하고, 관리자가 새 정책 버전을 등록하는 POST /api/admin/policies API를 추가한다. 원문 전체는 프론트엔드가 SSOT로 관리하므로 detailUrl로만 연결한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/exception/ErrorCode.java | 3 + .../policy/controller/PolicyController.java | 32 +++++++ .../policy/dto/PolicyRegisterRequest.java | 30 ++++++ .../Backend/policy/dto/PolicyResponse.java | 27 ++++++ .../Backend/policy/entity/PolicyDocument.java | 49 ++++++++++ .../Backend/policy/entity/PolicyType.java | 5 + .../PolicyVersionDuplicateException.java | 11 +++ .../repository/PolicyDocumentRepository.java | 10 ++ .../Backend/policy/service/PolicyService.java | 39 ++++++++ .../migration/V30__create_policy_document.sql | 11 +++ .../policy/service/PolicyServiceTest.java | 94 +++++++++++++++++++ 11 files changed, 311 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/controller/PolicyController.java create mode 100644 src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java create mode 100644 src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java create mode 100644 src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java create mode 100644 src/main/java/com/Coming/Backend/policy/entity/PolicyType.java create mode 100644 src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java create mode 100644 src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java create mode 100644 src/main/java/com/Coming/Backend/policy/service/PolicyService.java create mode 100644 src/main/resources/db/migration/V30__create_policy_document.sql create mode 100644 src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index dfd5f65..071545f 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -55,6 +55,9 @@ public enum ErrorCode { PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), + // Policy + POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."), + // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."), diff --git a/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java new file mode 100644 index 0000000..ad7d817 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java @@ -0,0 +1,32 @@ +package com.Coming.Backend.policy.controller; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.service.PolicyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Policy") +@RestController +@RequestMapping("/api/admin/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicyService policyService; + + @Operation(summary = "정책 버전 등록") + @ApiResponse(responseCode = "409", description = "POLICY_VERSION_DUPLICATE") + @PostMapping + public ResponseEntity registerPolicy(@Valid @RequestBody PolicyRegisterRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(policyService.registerPolicy(request)); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java new file mode 100644 index 0000000..84f05bc --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java @@ -0,0 +1,30 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record PolicyRegisterRequest( + @NotNull + PolicyType type, + + @NotBlank + @Size(max = 50) + String version, + + @NotNull + LocalDate effectiveDate, + + @NotBlank + String changeSummary, + + @NotBlank + @Size(max = 500) + String detailUrl, + + @NotNull + Boolean requiresReconsent +) { +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java new file mode 100644 index 0000000..2a232cf --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java @@ -0,0 +1,27 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; + +public record PolicyResponse( + Long id, + PolicyType type, + String version, + LocalDate effectiveDate, + String changeSummary, + String detailUrl, + boolean requiresReconsent +) { + public static PolicyResponse from(PolicyDocument policyDocument) { + return new PolicyResponse( + policyDocument.getId(), + policyDocument.getType(), + policyDocument.getVersion(), + policyDocument.getEffectiveDate(), + policyDocument.getChangeSummary(), + policyDocument.getDetailUrl(), + policyDocument.isRequiresReconsent() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java new file mode 100644 index 0000000..f7ac6a0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_document") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyDocument extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private PolicyType type; + + @Column(name = "version", nullable = false, length = 50) + private String version; + + @Column(name = "effective_date", nullable = false) + private LocalDate effectiveDate; + + @Column(name = "change_summary", nullable = false, columnDefinition = "text") + private String changeSummary; + + @Column(name = "detail_url", nullable = false, length = 500) + private String detailUrl; + + @Column(name = "requires_reconsent", nullable = false) + private boolean requiresReconsent; +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java new file mode 100644 index 0000000..d6eaf4a --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum PolicyType { + TERMS, PRIVACY +} diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java new file mode 100644 index 0000000..ab706a5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyVersionDuplicateException extends BusinessException { + + public PolicyVersionDuplicateException() { + super(ErrorCode.POLICY_VERSION_DUPLICATE); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java new file mode 100644 index 0000000..5c0f392 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java @@ -0,0 +1,10 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyDocumentRepository extends JpaRepository { + + boolean existsByTypeAndVersion(PolicyType type, String version); +} diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java new file mode 100644 index 0000000..f4ee761 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -0,0 +1,39 @@ +package com.Coming.Backend.policy.service; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyService { + + private final PolicyDocumentRepository policyDocumentRepository; + + /** + * 새 정책 버전을 등록한다. + * + * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우 + */ + @Transactional + public PolicyResponse registerPolicy(PolicyRegisterRequest request) { + if (policyDocumentRepository.existsByTypeAndVersion(request.type(), request.version())) { + throw new PolicyVersionDuplicateException(); + } + PolicyDocument policyDocument = PolicyDocument.builder() + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .requiresReconsent(Boolean.TRUE.equals(request.requiresReconsent())) + .build(); + return PolicyResponse.from(policyDocumentRepository.save(policyDocument)); + } +} diff --git a/src/main/resources/db/migration/V30__create_policy_document.sql b/src/main/resources/db/migration/V30__create_policy_document.sql new file mode 100644 index 0000000..6e1be50 --- /dev/null +++ b/src/main/resources/db/migration/V30__create_policy_document.sql @@ -0,0 +1,11 @@ +CREATE TABLE policy_document ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + type varchar(20) NOT NULL, + version varchar(50) NOT NULL, + effective_date date NOT NULL, + change_summary text NOT NULL, + detail_url varchar(500) NOT NULL, + requires_reconsent boolean NOT NULL DEFAULT false, + created_at timestamp, + UNIQUE (type, version) +); diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java new file mode 100644 index 0000000..0917026 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -0,0 +1,94 @@ +package com.Coming.Backend.policy.service; + +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; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +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; + +@ExtendWith(MockitoExtension.class) +class PolicyServiceTest { + + @InjectMocks + private PolicyService policyService; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + private static final PolicyType POLICY_TYPE = PolicyType.TERMS; + private static final String VERSION = "1.0.0"; + + private PolicyRegisterRequest buildRequest() { + return new PolicyRegisterRequest( + POLICY_TYPE, + VERSION, + LocalDate.of(2026, 1, 1), + "이용약관 최초 등록", + "https://coming.example.com/policy/terms/1.0.0", + true + ); + } + + private PolicyDocument buildPolicyDocument(Long id, PolicyRegisterRequest request) { + return PolicyDocument.builder() + .id(id) + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .requiresReconsent(request.requiresReconsent()) + .build(); + } + + @Test + void should_return_policy_response_when_valid_request_given() { + // given + PolicyRegisterRequest request = buildRequest(); + PolicyDocument savedPolicyDocument = buildPolicyDocument(1L, request); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + given(policyDocumentRepository.save(any(PolicyDocument.class))).willReturn(savedPolicyDocument); + + // when + PolicyResponse result = policyService.registerPolicy(request); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.type()).isEqualTo(POLICY_TYPE); + assertThat(result.version()).isEqualTo(VERSION); + assertThat(result.effectiveDate()).isEqualTo(request.effectiveDate()); + assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); + assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); + assertThat(result.requiresReconsent()).isTrue(); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_same_type_and_version_already_exists() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(true); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); + verify(policyDocumentRepository, never()).save(any(PolicyDocument.class)); + } +} From 53ac45f0a1798c32fd8ce1986ddb56f8e5f068c0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:39:32 +0900 Subject: [PATCH 41/54] =?UTF-8?q?[feat]=20UserPolicyAgreement=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=EB=8F=99=EC=9D=98=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B0=B1=ED=95=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: User의 agreed_terms/agreed_privacy boolean 필드를 대체할 UserPolicyAgreement(userId, policyId, agreedAt) 이력 테이블을 추가한다. 마이그레이션에서 레거시 PolicyDocument(TERMS/PRIVACY, version=legacy)를 시드하고, 기존에 agreed_terms/agreed_privacy=true였던 유저의 동의 이력을 agreed_at 기준으로 백필한다. 로컬 DB에 다른 브랜치의 V30/V31(post/comment 테이블)이 이미 적용되어 있어 정책 관련 마이그레이션을 V32/V33으로 재넘버링했다(V30__create_policy_document.sql 커밋 포함). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../policy/entity/UserPolicyAgreement.java | 36 +++++++++++++++++++ .../UserPolicyAgreementRepository.java | 9 +++++ ...nt.sql => V32__create_policy_document.sql} | 0 .../V33__create_user_policy_agreement.sql | 22 ++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java create mode 100644 src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java rename src/main/resources/db/migration/{V30__create_policy_document.sql => V32__create_policy_document.sql} (100%) create mode 100644 src/main/resources/db/migration/V33__create_user_policy_agreement.sql diff --git a/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java new file mode 100644 index 0000000..2c9158c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.policy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_policy_agreement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserPolicyAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "agreed_at", nullable = false) + private LocalDateTime agreedAt; +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java new file mode 100644 index 0000000..32f5ebb --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java @@ -0,0 +1,9 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPolicyAgreementRepository extends JpaRepository { + + boolean existsByUserIdAndPolicyId(Long userId, Long policyId); +} diff --git a/src/main/resources/db/migration/V30__create_policy_document.sql b/src/main/resources/db/migration/V32__create_policy_document.sql similarity index 100% rename from src/main/resources/db/migration/V30__create_policy_document.sql rename to src/main/resources/db/migration/V32__create_policy_document.sql diff --git a/src/main/resources/db/migration/V33__create_user_policy_agreement.sql b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql new file mode 100644 index 0000000..402e046 --- /dev/null +++ b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql @@ -0,0 +1,22 @@ +CREATE TABLE user_policy_agreement ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + policy_id bigint NOT NULL, + agreed_at timestamp NOT NULL, + UNIQUE (user_id, policy_id) +); + +INSERT INTO policy_document (type, version, effective_date, change_summary, detail_url, requires_reconsent, created_at) +VALUES + ('TERMS', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()), + ('PRIVACY', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()); + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'TERMS' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_terms = true AND agreed_at IS NOT NULL; + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'PRIVACY' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_privacy = true AND agreed_at IS NOT NULL; From 22785c21a26988c08e4f4f370f59d5b6b0eaf07e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:45:10 +0900 Subject: [PATCH 42/54] =?UTF-8?q?[feat]=20User=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EB=8F=99=EC=9D=98=20boolean=20=ED=95=84=EB=93=9C=EB=A5=BC=20Us?= =?UTF-8?q?erPolicyAgreement=20=EC=9D=B4=EB=A0=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: User.agreedTerms/agreedPrivacy/agreedAt 필드를 제거하고, 회원가입 완료 시 현재 시행 중인 TERMS/PRIVACY PolicyDocument에 대한 UserPolicyAgreement를 기록하도록 AuthService.register()를 리팩터링한다. 이미 동의 이력이 있으면 건너뛰어 탈퇴 후 재가입 시 유니크 제약 위반을 방지한다. 레거시 컬럼은 V34 마이그레이션으로 제거한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/auth/entity/User.java | 19 +-- .../Backend/auth/service/AuthService.java | 33 ++++- .../Backend/common/exception/ErrorCode.java | 1 + .../exception/PolicyNotFoundException.java | 11 ++ .../repository/PolicyDocumentRepository.java | 5 + .../V34__drop_legacy_agreement_columns.sql | 3 + .../auth/service/AuthServiceRegisterTest.java | 30 +++++ .../Backend/auth/service/AuthServiceTest.java | 121 +++++++++++++++++- 8 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java create mode 100644 src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index acb8070..1cc5b81 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -9,7 +9,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -51,26 +50,13 @@ public class User extends BaseTimeEntity { @Column(name = "birth_year") private Integer birthYear; - @Column(name = "agreed_terms") - private Boolean agreedTerms; - - @Column(name = "agreed_privacy") - private Boolean agreedPrivacy; - @Column(name = "agreed_marketing") private Boolean agreedMarketing; - @Column(name = "agreed_at") - private LocalDateTime agreedAt; - - public void completeRegistration(String nickname, int birthYear, - boolean agreedTerms, boolean agreedPrivacy, boolean agreedMarketing) { + public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) { this.nickname = nickname; this.birthYear = birthYear; - this.agreedTerms = agreedTerms; - this.agreedPrivacy = agreedPrivacy; this.agreedMarketing = agreedMarketing; - this.agreedAt = LocalDateTime.now(); this.role = UserRole.USER; } @@ -96,9 +82,6 @@ public void reactivate() { this.role = UserRole.PENDING; this.nickname = null; this.birthYear = null; - this.agreedTerms = null; - this.agreedPrivacy = null; this.agreedMarketing = null; - this.agreedAt = null; } } diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 9439be4..59b7687 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -25,6 +25,14 @@ import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -43,6 +51,8 @@ public class AuthService { private final UserFollowArtistRepository userFollowArtistRepository; private final UserConcertCalendarRepository userConcertCalendarRepository; private final InquiryRepository inquiryRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final UserPolicyAgreementRepository userPolicyAgreementRepository; /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. @@ -128,10 +138,10 @@ public TokenResponse register(Long userId, RegisterRequest request) { user.completeRegistration( request.nickname(), request.birthYear(), - request.agreedTerms(), - request.agreedPrivacy(), Boolean.TRUE.equals(request.agreedMarketing()) ); + recordPolicyAgreement(userId, PolicyType.TERMS); + recordPolicyAgreement(userId, PolicyType.PRIVACY); try { userRepository.flush(); } catch (DataIntegrityViolationException e) { @@ -142,6 +152,25 @@ public TokenResponse register(Long userId, RegisterRequest request) { return new TokenResponse(accessToken); } + /** + * 현재 시행 중인 정책 버전에 대한 사용자 동의 이력을 기록한다. 이미 동의 이력이 있으면 건너뛴다(탈퇴 후 재가입 대비). + */ + private void recordPolicyAgreement(Long userId, PolicyType type) { + PolicyDocument currentPolicy = policyDocumentRepository + .findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(type, LocalDate.now()) + .orElseThrow(PolicyNotFoundException::new); + if (userPolicyAgreementRepository.existsByUserIdAndPolicyId(userId, currentPolicy.getId())) { + return; + } + userPolicyAgreementRepository.save( + UserPolicyAgreement.builder() + .userId(userId) + .policyId(currentPolicy.getId()) + .agreedAt(LocalDateTime.now()) + .build() + ); + } + private void validateRegisterRequest(RegisterRequest request) { if (!Boolean.TRUE.equals(request.agreedTerms()) || !Boolean.TRUE.equals(request.agreedPrivacy())) { throw new TermsNotAgreedException(); diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 071545f..7a7cf9c 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -57,6 +57,7 @@ public enum ErrorCode { // Policy POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."), + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 정책입니다."), // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java new file mode 100644 index 0000000..4318931 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyNotFoundException extends BusinessException { + + public PolicyNotFoundException() { + super(ErrorCode.POLICY_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java index 5c0f392..9b76ace 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java @@ -2,9 +2,14 @@ import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PolicyDocumentRepository extends JpaRepository { boolean existsByTypeAndVersion(PolicyType type, String version); + + Optional findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + PolicyType type, LocalDate today); } diff --git a/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql new file mode 100644 index 0000000..2b2b9b1 --- /dev/null +++ b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" DROP COLUMN agreed_terms; +ALTER TABLE "user" DROP COLUMN agreed_privacy; +ALTER TABLE "user" DROP COLUMN agreed_at; diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java index b3dd54f..fa5d97b 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java @@ -24,9 +24,15 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -58,6 +64,12 @@ class AuthServiceRegisterTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String NEW_ACCESS_TOKEN = "new-access-token"; @@ -72,6 +84,18 @@ private User buildPendingUser() { .build(); } + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(type == PolicyType.TERMS ? 1L : 2L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("변경 요약") + .detailUrl("https://coming.example.com/policy") + .requiresReconsent(false) + .build(); + } + // ------------------------------------------------------------------------- // register // ------------------------------------------------------------------------- @@ -83,6 +107,12 @@ void should_return_token_and_change_role_to_user_when_registration_is_valid() { User user = buildPendingUser(); given(userRepository.existsByNickname("IU")).willReturn(false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.TERMS), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.TERMS))); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.PRIVACY), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.PRIVACY))); given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); // when diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 65fd9b2..b865b6b 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -2,9 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.Coming.Backend.auth.dto.MarketingUpdateRequest; @@ -29,9 +34,17 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; 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; @@ -63,6 +76,12 @@ class AuthServiceTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String REFRESH_TOKEN = "valid-refresh-token"; private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; @@ -80,6 +99,28 @@ private User buildUser() { .build(); } + private User buildPendingUser() { + return User.builder() + .id(USER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .provider("google") + .providerId("google-123") + .build(); + } + + private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { + return PolicyDocument.builder() + .id(policyId) + .type(type) + .version("1.0") + .effectiveDate(LocalDate.now()) + .changeSummary("최초 시행") + .detailUrl("https://coming.example.com/policy") + .requiresReconsent(false) + .build(); + } + // ------------------------------------------------------------------------- // refreshToken // ------------------------------------------------------------------------- @@ -329,16 +370,13 @@ void should_return_me_response_with_updated_nickname_when_nickname_is_given() { @Test void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_register() { // given - User user = User.builder() - .id(USER_ID) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .provider("google") - .providerId("google-123") - .build(); + User user = buildPendingUser(); RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + any(PolicyType.class), any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(10L, PolicyType.TERMS))); willThrow(DataIntegrityViolationException.class).given(userRepository).flush(); // when & then @@ -346,4 +384,73 @@ void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_re .isInstanceOf(NicknameDuplicateException.class) .hasMessage(ErrorCode.NICKNAME_DUPLICATE.getMessage()); } + + @Test + void should_save_user_policy_agreement_for_terms_and_privacy_when_register_succeeds() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + + // when + authService.register(USER_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserPolicyAgreement.class); + verify(userPolicyAgreementRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(UserPolicyAgreement::getUserId, UserPolicyAgreement::getPolicyId) + .containsExactlyInAnyOrder(tuple(USER_ID, 10L), tuple(USER_ID, 20L)); + } + + @Test + void should_not_save_user_policy_agreement_when_agreement_already_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 10L)).willReturn(true); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 20L)).willReturn(true); + + // when + authService.register(USER_ID, request); + + // then + verify(userPolicyAgreementRepository, never()).save(any()); + } + + @Test + void should_throw_PolicyNotFoundException_when_no_current_policy_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.register(USER_ID, request)) + .isInstanceOf(PolicyNotFoundException.class) + .hasMessage(ErrorCode.POLICY_NOT_FOUND.getMessage()); + } } From 211551fc2a5489a838cb09f77bda58909f95bfa2 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:46:12 +0900 Subject: [PATCH 43/54] =?UTF-8?q?[feat]=20PolicyNotificationTarget=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: 정책 변경 이메일 발송 대상과 발송 상태(PENDING/SENT/FAILED), 재시도 횟수를 추적하는 PolicyNotificationTarget을 추가한다. 배치 Job이 이 테이블을 기준으로 대상 생성과 발송 상태 갱신을 수행한다(후속 커밋). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../policy/entity/NotificationStatus.java | 5 ++ .../entity/PolicyNotificationTarget.java | 46 +++++++++++++++++++ .../PolicyNotificationTargetRepository.java | 7 +++ ...V35__create_policy_notification_target.sql | 10 ++++ 4 files changed, 68 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java create mode 100644 src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java create mode 100644 src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java create mode 100644 src/main/resources/db/migration/V35__create_policy_notification_target.sql diff --git a/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java new file mode 100644 index 0000000..774b45c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum NotificationStatus { + PENDING, SENT, FAILED +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java new file mode 100644 index 0000000..f39b14d --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -0,0 +1,46 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_notification_target") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyNotificationTarget extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private NotificationStatus status; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java new file mode 100644 index 0000000..dd975de --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyNotificationTargetRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V35__create_policy_notification_target.sql b/src/main/resources/db/migration/V35__create_policy_notification_target.sql new file mode 100644 index 0000000..4e01116 --- /dev/null +++ b/src/main/resources/db/migration/V35__create_policy_notification_target.sql @@ -0,0 +1,10 @@ +CREATE TABLE policy_notification_target ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + policy_id bigint NOT NULL, + user_id bigint NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + sent_at timestamp, + retry_count integer NOT NULL DEFAULT 0, + created_at timestamp, + UNIQUE (policy_id, user_id) +); From 3c542e0cfb729a55b1c5845eee591fd90310ef6f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:49:54 +0900 Subject: [PATCH 44/54] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B3=A0=EC=A7=80=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: Gmail SMTP 기반 JavaMailSender 설정과 Thymeleaf 이메일 템플릿(policy-change-notice.html)을 추가한다. 법적 고지 목적상 수신거부 링크는 넣지 않고, 발신자 정보·변경사항 요약(changeSummary)· 시행일자를 본문에 필수로 노출한다. requiresReconsent인 경우 재동의 필요 안내를 추가로 표시한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- build.gradle | 2 + .../policy/mail/PolicyNoticeMailSender.java | 65 +++++++++ src/main/resources/application.yaml | 12 ++ .../templates/mail/policy-change-notice.html | 48 +++++++ .../mail/PolicyNoticeMailSenderTest.java | 133 ++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java create mode 100644 src/main/resources/templates/mail/policy-change-notice.html create mode 100644 src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java diff --git a/build.gradle b/build.gradle index 78e0038..08a77f2 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java new file mode 100644 index 0000000..e725d8d --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -0,0 +1,65 @@ +package com.Coming.Backend.policy.mail; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Component +@RequiredArgsConstructor +public class PolicyNoticeMailSender { + + private static final String TEMPLATE_NAME = "mail/policy-change-notice"; + + private final JavaMailSender javaMailSender; + private final TemplateEngine templateEngine; + + @Value("${spring.mail.username}") + private String fromAddress; + + /** + * 정책 변경 고지 메일을 발송한다. + * + * @throws IllegalStateException 메일 메시지 생성에 실패한 경우 + */ + public void send(String toEmail, PolicyDocument policyDocument) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + helper.setTo(toEmail); + helper.setFrom(fromAddress); + helper.setSubject(buildSubject(policyDocument)); + helper.setText(renderHtml(policyDocument), true); + javaMailSender.send(message); + } catch (MessagingException e) { + throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e); + } + } + + private String buildSubject(PolicyDocument policyDocument) { + return "[Coming] " + labelOf(policyDocument.getType()) + " 변경 안내"; + } + + private String renderHtml(PolicyDocument policyDocument) { + Context context = new Context(); + context.setVariable("policyTypeLabel", labelOf(policyDocument.getType())); + context.setVariable("version", policyDocument.getVersion()); + context.setVariable("effectiveDate", policyDocument.getEffectiveDate()); + context.setVariable("changeSummary", policyDocument.getChangeSummary()); + context.setVariable("detailUrl", policyDocument.getDetailUrl()); + context.setVariable("requiresReconsent", policyDocument.isRequiresReconsent()); + context.setVariable("fromAddress", fromAddress); + return templateEngine.process(TEMPLATE_NAME, context); + } + + private String labelOf(PolicyType type) { + return type == PolicyType.TERMS ? "이용약관" : "개인정보처리방침"; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 66b29c4..4a24e9d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -61,6 +61,18 @@ spring: pageable: max-page-size: 50 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + jwt: secret: ${JWT_SECRET} access-token-expiry: 1800000 diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html new file mode 100644 index 0000000..a6972b6 --- /dev/null +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -0,0 +1,48 @@ + + + + + 정책 변경 안내 + + +
+

+ 이용약관 변경 안내 +

+ +

+ 안녕하세요, Coming입니다.
+ 이용약관이 아래와 같이 변경되어 안내드립니다. +

+ + + + + + + + + + +
시행일자2026-01-01
변경 내용변경 내용 요약
+ +

+ 본 변경사항은 재동의가 필요한 사항입니다. 서비스 계속 이용을 위해 앱 또는 웹에서 재동의를 완료해주세요. +

+ +

+ 전체 원문은 아래 링크에서 확인하실 수 있습니다.
+ + https://coming.example.com/policy + +

+ +
+ +

+ 본 메일은 관련 법령에 따른 정책 변경 사전 고지 목적으로 발송되는 필수 안내이며, 수신거부가 적용되지 않습니다.
+ 발신: Coming (coming@example.com) +

+
+ + diff --git a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java new file mode 100644 index 0000000..91668e6 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java @@ -0,0 +1,133 @@ +package com.Coming.Backend.policy.mail; + +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; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@ExtendWith(MockitoExtension.class) +class PolicyNoticeMailSenderTest { + + @InjectMocks + private PolicyNoticeMailSender policyNoticeMailSender; + + @Mock + private JavaMailSender javaMailSender; + + @Mock + private TemplateEngine templateEngine; + + private static final String FROM_ADDRESS = "coming@example.com"; + private static final String TO_EMAIL = "user@example.com"; + + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(1L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("개인정보 수집 항목 변경") + .detailUrl("https://coming.example.com/policy/1.0.0") + .requiresReconsent(true) + .build(); + } + + @Test + void should_send_mail_when_valid_policy_document_given() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), any(Context.class)); + verify(javaMailSender).send(any(MimeMessage.class)); + } + + @Test + void should_pass_policy_document_fields_to_template_context_when_send_called() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + Context capturedContext = contextCaptor.getValue(); + assertThat(capturedContext.getVariable("changeSummary")).isEqualTo(policyDocument.getChangeSummary()); + assertThat(capturedContext.getVariable("effectiveDate")).isEqualTo(policyDocument.getEffectiveDate()); + assertThat(capturedContext.getVariable("detailUrl")).isEqualTo(policyDocument.getDetailUrl()); + assertThat(capturedContext.getVariable("requiresReconsent")).isEqualTo(policyDocument.isRequiresReconsent()); + } + + @Test + void should_set_terms_label_when_policy_type_is_terms() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("이용약관"); + } + + @Test + void should_set_privacy_label_when_policy_type_is_privacy() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("개인정보처리방침"); + } +} From f88ab4ebaf4e923791d6a5803d1fa9ed4ef6222d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:55:33 +0900 Subject: [PATCH 45/54] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=8C=80=EC=83=81=20=EC=83=9D=EC=84=B1=20Batch=20S?= =?UTF-8?q?tep1=20Tasklet=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: Spring Batch를 도입하고, 정책 발행 시 ACTIVE 유저 중 이메일이 있고 아직 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록하는 Tasklet을 추가한다. 배치 스키마는 Flyway(V36)로 관리하고, spring.batch.job.enabled=false로 앱 기동 시 자동 실행되지 않도록 한다(트리거는 후속 커밋). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- build.gradle | 1 + .../auth/repository/UserRepository.java | 3 + ...licyNotificationTargetCreationTasklet.java | 62 +++++++ .../PolicyNotificationTargetRepository.java | 3 + src/main/resources/application.yaml | 6 + .../V36__create_spring_batch_schema.sql | 79 +++++++++ ...NotificationTargetCreationTaskletTest.java | 151 ++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java create mode 100644 src/main/resources/db/migration/V36__create_spring_batch_schema.sql create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java diff --git a/build.gradle b/build.gradle index 08a77f2..786eb83 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' diff --git a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java index cf1542f..d462660 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.Coming.Backend.auth.repository; import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -14,4 +15,6 @@ public interface UserRepository extends JpaRepository { List findAllByIdIn(Collection ids); boolean existsByNickname(String nickname); + + List findByStatus(UserStatus status); } diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java new file mode 100644 index 0000000..0e229a8 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java @@ -0,0 +1,62 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * 정책 발행 시 ACTIVE 유저 중 아직 발송 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetCreationTasklet implements Tasklet { + + private final UserRepository userRepository; + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + Long policyId = chunkContext.getStepContext().getStepExecution().getJobParameters().getLong("policyId"); + + Set existingTargetUserIds = policyNotificationTargetRepository.findByPolicyId(policyId).stream() + .map(PolicyNotificationTarget::getUserId) + .collect(Collectors.toSet()); + + List activeUsers = userRepository.findByStatus(UserStatus.ACTIVE); + int created = 0; + for (User user : activeUsers) { + if (existingTargetUserIds.contains(user.getId())) { + continue; + } + if (user.getEmail() == null || user.getEmail().isBlank()) { + log.warn("이메일이 없어 정책 알림 대상에서 제외 — userId: {}", user.getId()); + continue; + } + policyNotificationTargetRepository.save( + PolicyNotificationTarget.builder() + .policyId(policyId) + .userId(user.getId()) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build() + ); + created++; + } + log.info("정책 알림 대상 생성 완료 — policyId: {}, 생성 건수: {}", policyId, created); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index dd975de..109c7be 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -1,7 +1,10 @@ package com.Coming.Backend.policy.repository; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface PolicyNotificationTargetRepository extends JpaRepository { + + List findByPolicyId(Long policyId); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4a24e9d..72f5938 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -73,6 +73,12 @@ spring: starttls: enable: true + batch: + job: + enabled: false + jdbc: + initialize-schema: never + jwt: secret: ${JWT_SECRET} access-token-expiry: 1800000 diff --git a/src/main/resources/db/migration/V36__create_spring_batch_schema.sql b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql new file mode 100644 index 0000000..b52b1b2 --- /dev/null +++ b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql @@ -0,0 +1,79 @@ +-- Spring Batch 6.0.3 공식 PostgreSQL 스키마(schema-postgresql.sql)를 그대로 사용한다. +-- spring.batch.jdbc.initialize-schema=never로 설정해 부트 자동 초기화 대신 Flyway로 관리한다. + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java new file mode 100644 index 0000000..18a3702 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java @@ -0,0 +1,151 @@ +package com.Coming.Backend.policy.batch; + +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.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +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.batch.core.job.JobExecution; +import org.springframework.batch.core.job.JobInstance; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.StepExecution; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetCreationTaskletTest { + + @InjectMocks + private PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private User buildActiveUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private ChunkContext buildChunkContext(Long policyId) { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("policyId", policyId) + .toJobParameters(); + JobInstance jobInstance = new JobInstance(1L, "policyNotificationJob"); + JobExecution jobExecution = new JobExecution(1L, jobInstance, jobParameters); + StepExecution stepExecution = new StepExecution(1L, "targetCreationStep", jobExecution); + return new ChunkContext(new StepContext(stepExecution)); + } + + @Test + void should_save_pending_target_when_active_user_with_email_and_no_existing_target_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository).save(targetCaptor.capture()); + PolicyNotificationTarget savedTarget = targetCaptor.getValue(); + assertThat(savedTarget.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(savedTarget.getUserId()).isEqualTo(1L); + assertThat(savedTarget.getStatus()).isEqualTo(NotificationStatus.PENDING); + assertThat(savedTarget.getRetryCount()).isZero(); + } + + @Test + void should_skip_user_when_target_already_exists_for_policy_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + PolicyNotificationTarget existingTarget = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of(existingTarget)); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_skip_user_when_email_is_null_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, null); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_return_finished_when_execute_completes_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of()); + + // when + RepeatStatus result = policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + } +} From ce9ce77c783961866137d724d92b362fe9b02b9f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:05:01 +0900 Subject: [PATCH 46/54] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20Batch=20S?= =?UTF-8?q?tep2=20=EB=B0=8F=20Job=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: PENDING 상태 PolicyNotificationTarget을 청크 단위로 읽어 메일을 발송하고 상태(SENT/FAILED)를 갱신하는 Step2를 추가하고, Step1(대상 생성)과 Step2(메일 발송)를 policyNotificationJob으로 조립한다. 발송 실패(SMTP 예외 포함)는 FAILED로 마킹되어 재시도 대상이 된다(재시도 스케줄러는 후속 커밋). 임시 DB에 V1~V36 전체 마이그레이션을 적용해 실제 앱을 기동, Job/Step/ Reader/Processor/Writer 빈 그래프가 정상 등록되는지 확인했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../batch/PolicyNotificationBatchConfig.java | 77 ++++++++ .../PolicyNotificationMailProcessor.java | 59 ++++++ .../batch/PolicyNotificationTargetWriter.java | 20 ++ .../entity/PolicyNotificationTarget.java | 10 + .../PolicyNotificationTargetRepository.java | 5 + .../PolicyNotificationMailProcessorTest.java | 173 ++++++++++++++++++ .../PolicyNotificationTargetWriterTest.java | 56 ++++++ 7 files changed, 400 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java new file mode 100644 index 0000000..87af58e --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java @@ -0,0 +1,77 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.data.RepositoryItemReader; +import org.springframework.batch.infrastructure.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class PolicyNotificationBatchConfig { + + private static final int CHUNK_SIZE = 20; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final PolicyNotificationMailProcessor policyNotificationMailProcessor; + private final PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Bean + public Job policyNotificationJob() { + return new JobBuilder("policyNotificationJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(targetCreationStep()) + .next(mailSendStep()) + .build(); + } + + @Bean + public Step targetCreationStep() { + return new StepBuilder("targetCreationStep", jobRepository) + .tasklet(policyNotificationTargetCreationTasklet, transactionManager) + .build(); + } + + @Bean + public Step mailSendStep() { + return new StepBuilder("mailSendStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(mailSendItemReader(null)) + .processor(policyNotificationMailProcessor) + .writer(policyNotificationTargetWriter) + .transactionManager(transactionManager) + .build(); + } + + @Bean + @StepScope + public RepositoryItemReader mailSendItemReader( + @Value("#{jobParameters['policyId']}") Long policyId) { + return new RepositoryItemReaderBuilder() + .name("mailSendItemReader") + .repository(policyNotificationTargetRepository) + .methodName("findByPolicyIdAndStatus") + .arguments(List.of(policyId, NotificationStatus.PENDING)) + .sorts(Map.of("id", Sort.Direction.ASC)) + .pageSize(CHUNK_SIZE) + .build(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java new file mode 100644 index 0000000..7ebafe4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java @@ -0,0 +1,59 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationMailProcessor implements ItemProcessor { + + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNoticeMailSender policyNoticeMailSender; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private PolicyDocument policyDocument; + + @Override + public PolicyNotificationTarget process(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + if (user == null || user.getEmail() == null || user.getEmail().isBlank()) { + log.warn("수신 이메일이 없어 발송 실패 처리 — targetId: {}", target.getId()); + target.markFailed(); + return target; + } + try { + policyNoticeMailSender.send(user.getEmail(), resolvePolicyDocument()); + target.markSent(); + } catch (Exception e) { + log.warn("정책 변경 고지 메일 발송 실패 — targetId: {}", target.getId(), e); + target.markFailed(); + } + return target; + } + + private PolicyDocument resolvePolicyDocument() { + if (policyDocument == null) { + policyDocument = policyDocumentRepository.findById(policyId).orElseThrow(PolicyNotFoundException::new); + } + return policyDocument; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java new file mode 100644 index 0000000..b9f946e --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java @@ -0,0 +1,20 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.Chunk; +import org.springframework.batch.infrastructure.item.ItemWriter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetWriter implements ItemWriter { + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public void write(Chunk chunk) { + policyNotificationTargetRepository.saveAll(chunk.getItems()); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java index f39b14d..a8aad82 100644 --- a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -43,4 +43,14 @@ public class PolicyNotificationTarget extends BaseCreatedEntity { @Column(name = "retry_count", nullable = false) private int retryCount; + + public void markSent() { + this.status = NotificationStatus.SENT; + this.sentAt = LocalDateTime.now(); + } + + public void markFailed() { + this.status = NotificationStatus.FAILED; + this.retryCount += 1; + } } diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index 109c7be..74e5f65 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -1,10 +1,15 @@ package com.Coming.Backend.policy.repository; +import com.Coming.Backend.policy.entity.NotificationStatus; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface PolicyNotificationTargetRepository extends JpaRepository { List findByPolicyId(Long policyId); + + Page findByPolicyIdAndStatus(Long policyId, NotificationStatus status, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java new file mode 100644 index 0000000..2d92015 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java @@ -0,0 +1,173 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +import java.util.Optional; +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.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationMailProcessorTest { + + @InjectMocks + private PolicyNotificationMailProcessor policyNotificationMailProcessor; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .requiresReconsent(false) + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_mark_sent_when_user_with_email_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(result.getSentAt()).isNotNull(); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_when_user_not_found_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_user_email_is_null_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, null); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(result.getRetryCount()).isEqualTo(1); + } + + @Test + void should_call_policy_document_repository_only_once_when_process_called_twice_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationMailProcessor.process(buildTarget(1L)); + policyNotificationMailProcessor.process(buildTarget(1L)); + + // then + verify(policyDocumentRepository, times(1)).findById(POLICY_ID); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java new file mode 100644 index 0000000..1d3f3eb --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java @@ -0,0 +1,56 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +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.batch.infrastructure.item.Chunk; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetWriterTest { + + @InjectMocks + private PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long userId, NotificationStatus status) { + return PolicyNotificationTarget.builder() + .id(userId) + .policyId(POLICY_ID) + .userId(userId) + .status(status) + .retryCount(0) + .build(); + } + + @Test + void should_save_all_chunk_items_when_write_called_given() { + // given + PolicyNotificationTarget sentTarget = buildTarget(1L, NotificationStatus.SENT); + PolicyNotificationTarget failedTarget = buildTarget(2L, NotificationStatus.FAILED); + Chunk chunk = new Chunk<>(List.of(sentTarget, failedTarget)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + + // when + policyNotificationTargetWriter.write(chunk); + + // then + verify(policyNotificationTargetRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).containsExactly(sentTarget, failedTarget); + } +} From 6e11f2aac8c692e938472db6986a0e485b802618 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:11:22 +0900 Subject: [PATCH 47/54] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=9C=EC=86=A1=20=EC=8B=A4=ED=8C=A8=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: 정책 등록 트랜잭션 커밋 후(@TransactionalEventListener AFTER_COMMIT) policyNotificationJob을 동기로 실행하는 트리거를 추가한다. 발송 실패(FAILED) 대상은 별도 스케줄러가 1시간 간격으로 최대 3회까지 재시도한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Coming/Backend/BackendApplication.java | 2 + .../batch/PolicyNotificationJobTrigger.java | 38 ++++ .../PolicyNotificationRetryScheduler.java | 66 +++++++ .../policy/event/PolicyRegisteredEvent.java | 4 + .../PolicyNotificationTargetRepository.java | 2 + .../Backend/policy/service/PolicyService.java | 9 +- .../PolicyNotificationJobTriggerTest.java | 61 ++++++ .../PolicyNotificationRetrySchedulerTest.java | 179 ++++++++++++++++++ .../policy/service/PolicyServiceTest.java | 7 + 9 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java create mode 100644 src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java diff --git a/src/main/java/com/Coming/Backend/BackendApplication.java b/src/main/java/com/Coming/Backend/BackendApplication.java index 43fa2ad..1153006 100644 --- a/src/main/java/com/Coming/Backend/BackendApplication.java +++ b/src/main/java/com/Coming/Backend/BackendApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java new file mode 100644 index 0000000..e6cf97b --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java @@ -0,0 +1,38 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 동기로 실행한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationJobTrigger { + + private final JobOperator jobOperator; + private final Job policyNotificationJob; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPolicyRegistered(PolicyRegisteredEvent event) { + try { + JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( + new JobParametersBuilder() + .addLong("policyId", event.policyId()) + .toJobParameters() + ); + jobOperator.start(policyNotificationJob, jobParameters); + } catch (Exception e) { + log.error("정책 알림 배치 실행 실패 — policyId: {}", event.policyId(), e); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java new file mode 100644 index 0000000..c2115ad --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java @@ -0,0 +1,66 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 발송 실패(FAILED)한 정책 알림 대상을 최대 3회까지 1시간 간격으로 재시도한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationRetryScheduler { + + private static final int MAX_RETRY_COUNT = 3; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNoticeMailSender policyNoticeMailSender; + + @Scheduled(fixedRate = 3_600_000) + @Transactional + public void retryFailedNotifications() { + List retryTargets = policyNotificationTargetRepository + .findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, MAX_RETRY_COUNT); + if (retryTargets.isEmpty()) { + return; + } + log.info("정책 알림 재시도 시작 — 대상 건수: {}", retryTargets.size()); + for (PolicyNotificationTarget target : retryTargets) { + retryOne(target); + } + } + + private void retryOne(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + if (user == null || user.getEmail() == null || user.getEmail().isBlank()) { + target.markFailed(); + return; + } + PolicyDocument policyDocument = policyDocumentRepository.findById(target.getPolicyId()).orElse(null); + if (policyDocument == null) { + target.markFailed(); + return; + } + try { + policyNoticeMailSender.send(user.getEmail(), policyDocument); + target.markSent(); + } catch (Exception e) { + log.warn("정책 알림 재시도 실패 — targetId: {}", target.getId(), e); + target.markFailed(); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java new file mode 100644 index 0000000..60e0886 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.policy.event; + +public record PolicyRegisteredEvent(Long policyId) { +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index 74e5f65..ac915c0 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -12,4 +12,6 @@ public interface PolicyNotificationTargetRepository extends JpaRepository findByPolicyId(Long policyId); Page findByPolicyIdAndStatus(Long policyId, NotificationStatus status, Pageable pageable); + + List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount); } diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java index f4ee761..977f069 100644 --- a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -3,9 +3,11 @@ import com.Coming.Backend.policy.dto.PolicyRegisterRequest; import com.Coming.Backend.policy.dto.PolicyResponse; import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,9 +17,10 @@ public class PolicyService { private final PolicyDocumentRepository policyDocumentRepository; + private final ApplicationEventPublisher eventPublisher; /** - * 새 정책 버전을 등록한다. + * 새 정책 버전을 등록한다. 등록 완료(커밋) 후 정책 알림 배치가 트리거된다. * * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우 */ @@ -34,6 +37,8 @@ public PolicyResponse registerPolicy(PolicyRegisterRequest request) { .detailUrl(request.detailUrl()) .requiresReconsent(Boolean.TRUE.equals(request.requiresReconsent())) .build(); - return PolicyResponse.from(policyDocumentRepository.save(policyDocument)); + PolicyDocument saved = policyDocumentRepository.save(policyDocument); + eventPublisher.publishEvent(new PolicyRegisteredEvent(saved.getId())); + return PolicyResponse.from(saved); } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java new file mode 100644 index 0000000..28dc0c1 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java @@ -0,0 +1,61 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +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.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.launch.JobOperator; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationJobTriggerTest { + + @InjectMocks + private PolicyNotificationJobTrigger policyNotificationJobTrigger; + + @Mock + private JobOperator jobOperator; + + @Mock + private Job policyNotificationJob; + + @Test + void should_start_job_with_policy_id_when_policy_registered_event_given() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when + policyNotificationJobTrigger.onPolicyRegistered(event); + + // then + ArgumentCaptor jobParametersCaptor = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(policyNotificationJob), jobParametersCaptor.capture()); + assertThat(jobParametersCaptor.getValue().getLong("policyId")).isEqualTo(1L); + } + + @Test + void should_not_throw_exception_when_job_operator_start_throws_exception() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + willThrow(new RuntimeException("job launch failed")).given(jobOperator).start(any(Job.class), any(JobParameters.class)); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when & then + assertThatCode(() -> policyNotificationJobTrigger.onPolicyRegistered(event)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java new file mode 100644 index 0000000..ca813e1 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -0,0 +1,179 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationRetrySchedulerTest { + + @InjectMocks + private PolicyNotificationRetryScheduler policyNotificationRetryScheduler; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .requiresReconsent(false) + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.FAILED) + .retryCount(1) + .build(); + } + + @Test + void should_do_nothing_when_no_retry_targets_given() { + // given + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(Collections.emptyList()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verifyNoInteractions(userRepository, policyDocumentRepository, policyNoticeMailSender); + } + + @Test + void should_mark_sent_when_retry_succeeds() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_when_user_not_found() { + // given + PolicyNotificationTarget target = buildTarget(1L); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_policy_document_not_found() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java index 0917026..1bf8e0e 100644 --- a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -12,6 +12,7 @@ import com.Coming.Backend.policy.dto.PolicyResponse; import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import java.time.LocalDate; @@ -20,6 +21,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class PolicyServiceTest { @@ -30,6 +32,9 @@ class PolicyServiceTest { @Mock private PolicyDocumentRepository policyDocumentRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + private static final PolicyType POLICY_TYPE = PolicyType.TERMS; private static final String VERSION = "1.0.0"; @@ -76,6 +81,7 @@ void should_return_policy_response_when_valid_request_given() { assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); assertThat(result.requiresReconsent()).isTrue(); + verify(eventPublisher).publishEvent(new PolicyRegisteredEvent(1L)); } @Test @@ -90,5 +96,6 @@ void should_throw_policy_version_duplicate_exception_when_same_type_and_version_ .isInstanceOf(PolicyVersionDuplicateException.class) .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); verify(policyDocumentRepository, never()).save(any(PolicyDocument.class)); + verify(eventPublisher, never()).publishEvent(any()); } } From 38507762cb696d8c32a3f018f180c33d9cefa0fb Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:20:03 +0900 Subject: [PATCH 48/54] =?UTF-8?q?[fix]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EC=9D=98=20JobOperator=20=EC=82=AC=EC=9A=A9=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 임시 DB에 실제 앱을 기동해 관리자 API로 정책 등록 → 배치 실행까지 end-to-end로 검증한 결과 두 가지 실행 시점 버그를 발견해 수정했다. - JobOperator.start(Job, JobParameters)는 인크리멘터가 설정된 Job에서 run.id 외의 커스텀 파라미터(policyId)를 무시하고 버림 → run(...)으로 전환 - @TransactionalEventListener(AFTER_COMMIT) 콜백과 같은 스레드에서 자체 트랜잭션을 가진 배치를 실행하면 트랜잭션 동기화 상태 충돌로 JobInterruptedException 발생 → 별도 스레드에서 실행 후 join으로 대기 수정 후 Step1(대상 생성) → Step2(메일 발송, 실패 시 FAILED 처리) → Job COMPLETED까지 실제 동작을 확인했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../batch/PolicyNotificationJobTrigger.java | 21 +++++++++++++++---- .../PolicyNotificationJobTriggerTest.java | 8 +++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java index e6cf97b..784d555 100644 --- a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java @@ -12,7 +12,10 @@ import org.springframework.transaction.event.TransactionalEventListener; /** - * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 동기로 실행한다. + * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다. + * AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던 + * 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고 + * join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다. */ @Slf4j @Component @@ -24,15 +27,25 @@ public class PolicyNotificationJobTrigger { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onPolicyRegistered(PolicyRegisteredEvent event) { + Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId())); + try { + jobThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e); + } + } + + private void runJob(Long policyId) { try { JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( new JobParametersBuilder() - .addLong("policyId", event.policyId()) + .addLong("policyId", policyId) .toJobParameters() ); - jobOperator.start(policyNotificationJob, jobParameters); + jobOperator.run(policyNotificationJob, jobParameters); } catch (Exception e) { - log.error("정책 알림 배치 실행 실패 — policyId: {}", event.policyId(), e); + log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e); } } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java index 28dc0c1..87daa75 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java @@ -33,7 +33,7 @@ class PolicyNotificationJobTriggerTest { private Job policyNotificationJob; @Test - void should_start_job_with_policy_id_when_policy_registered_event_given() throws Exception { + void should_run_job_with_policy_id_when_policy_registered_event_given() throws Exception { // given given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); @@ -43,15 +43,15 @@ void should_start_job_with_policy_id_when_policy_registered_event_given() throws // then ArgumentCaptor jobParametersCaptor = ArgumentCaptor.forClass(JobParameters.class); - verify(jobOperator).start(eq(policyNotificationJob), jobParametersCaptor.capture()); + verify(jobOperator).run(eq(policyNotificationJob), jobParametersCaptor.capture()); assertThat(jobParametersCaptor.getValue().getLong("policyId")).isEqualTo(1L); } @Test - void should_not_throw_exception_when_job_operator_start_throws_exception() throws Exception { + void should_not_throw_exception_when_job_operator_run_throws_exception() throws Exception { // given given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); - willThrow(new RuntimeException("job launch failed")).given(jobOperator).start(any(Job.class), any(JobParameters.class)); + willThrow(new RuntimeException("job launch failed")).given(jobOperator).run(any(Job.class), any(JobParameters.class)); PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); // when & then From 9a482e6c397bf52427da1144f3435f074ca5839b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:31:09 +0900 Subject: [PATCH 49/54] =?UTF-8?q?[refactor]=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20PolicyService=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=95=88=EC=A0=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify 4개 관점(Reuse/Simplification/Efficiency/Altitude) 병렬 리뷰 결과를 반영한다. - PolicyNotificationMailProcessor와 PolicyNotificationRetryScheduler에 중복되던 "유저 조회 → 이메일 확인 → 발송 시도 → SENT/FAILED 마킹" 로직을 PolicyNotificationSender로 추출 - User.hasNoEmail()을 추가해 3곳에 흩어져 있던 이메일 공백 체크를 통합 - PolicyService.registerPolicy()에 기존 코드베이스 관례(AuthService, CalendarService와 동일한 존재 체크 + DataIntegrityViolationException catch)를 적용해 동시 등록 시 유니크 제약 위반을 도메인 예외로 변환 리팩터링 후 임시 DB에 실제 앱을 재기동해 정책 등록 → 배치 실행까지 동일하게 정상 동작함을 재검증했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/auth/entity/User.java | 4 + .../PolicyNotificationMailProcessor.java | 18 +-- .../PolicyNotificationRetryScheduler.java | 15 +- .../batch/PolicyNotificationSender.java | 36 +++++ ...licyNotificationTargetCreationTasklet.java | 2 +- .../Backend/policy/service/PolicyService.java | 8 +- .../PolicyNotificationMailProcessorTest.java | 77 ++++------ .../PolicyNotificationRetrySchedulerTest.java | 48 ++----- .../batch/PolicyNotificationSenderTest.java | 133 ++++++++++++++++++ .../policy/service/PolicyServiceTest.java | 22 ++- 10 files changed, 247 insertions(+), 116 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index 1cc5b81..7d9421e 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -77,6 +77,10 @@ public void updateEmail(String email) { this.email = email; } + public boolean hasNoEmail() { + return email == null || email.isBlank(); + } + public void reactivate() { this.status = UserStatus.ACTIVE; this.role = UserRole.PENDING; diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java index 7ebafe4..00c0ef1 100644 --- a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java @@ -5,10 +5,8 @@ import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import com.Coming.Backend.policy.exception.PolicyNotFoundException; -import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.infrastructure.item.ItemProcessor; import org.springframework.beans.factory.annotation.Value; @@ -17,7 +15,6 @@ /** * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. */ -@Slf4j @Component @StepScope @RequiredArgsConstructor @@ -25,7 +22,7 @@ public class PolicyNotificationMailProcessor implements ItemProcessor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + ArgumentCaptor policyDocumentCaptor = ArgumentCaptor.forClass(PolicyDocument.class); // when PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); // then - assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); - verify(policyNoticeMailSender, never()).send(any(), any()); + assertThat(result).isSameAs(target); + verify(policyNotificationSender).sendAndMark(targetCaptor.capture(), userCaptor.capture(), policyDocumentCaptor.capture()); + assertThat(targetCaptor.getValue()).isSameAs(target); + assertThat(userCaptor.getValue()).isSameAs(user); + assertThat(policyDocumentCaptor.getValue()).isSameAs(policyDocument); } @Test - void should_mark_failed_when_user_email_is_null_given() { + void should_call_sender_with_null_user_when_user_not_found_given() { // given ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); PolicyNotificationTarget target = buildTarget(1L); - User user = buildUser(1L, null); + PolicyDocument policyDocument = buildPolicyDocument(); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); // when - PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + policyNotificationMailProcessor.process(target); // then - assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); - verify(policyNoticeMailSender, never()).send(any(), any()); + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); } @Test - void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception_given() { + void should_call_policy_document_repository_only_once_when_process_called_twice_given() { // given ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); - PolicyNotificationTarget target = buildTarget(1L); User user = buildUser(1L, "iu@coming.com"); PolicyDocument policyDocument = buildPolicyDocument(); given(userRepository.findById(1L)).willReturn(Optional.of(user)); given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); - willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); // when - PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + policyNotificationMailProcessor.process(buildTarget(1L)); + policyNotificationMailProcessor.process(buildTarget(1L)); // then - assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); - assertThat(result.getRetryCount()).isEqualTo(1); + verify(policyDocumentRepository, times(1)).findById(POLICY_ID); } @Test - void should_call_policy_document_repository_only_once_when_process_called_twice_given() { + void should_throw_policy_not_found_exception_when_policy_document_not_found_given() { // given ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); - User user = buildUser(1L, "iu@coming.com"); - PolicyDocument policyDocument = buildPolicyDocument(); - - given(userRepository.findById(1L)).willReturn(Optional.of(user)); - given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + PolicyNotificationTarget target = buildTarget(1L); - // when - policyNotificationMailProcessor.process(buildTarget(1L)); - policyNotificationMailProcessor.process(buildTarget(1L)); + given(userRepository.findById(1L)).willReturn(Optional.of(buildUser(1L, "iu@coming.com"))); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); - // then - verify(policyDocumentRepository, times(1)).findById(POLICY_ID); + // when & then + assertThatThrownBy(() -> policyNotificationMailProcessor.process(target)) + .isInstanceOf(PolicyNotFoundException.class); } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java index ca813e1..e26a8f8 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -16,7 +15,6 @@ import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import com.Coming.Backend.policy.entity.PolicyType; -import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; import java.time.LocalDate; @@ -45,7 +43,7 @@ class PolicyNotificationRetrySchedulerTest { private PolicyDocumentRepository policyDocumentRepository; @Mock - private PolicyNoticeMailSender policyNoticeMailSender; + private PolicyNotificationSender policyNotificationSender; private static final Long POLICY_ID = 1L; @@ -95,37 +93,19 @@ void should_do_nothing_when_no_retry_targets_given() { policyNotificationRetryScheduler.retryFailedNotifications(); // then - verifyNoInteractions(userRepository, policyDocumentRepository, policyNoticeMailSender); + verifyNoInteractions(userRepository, policyDocumentRepository, policyNotificationSender); } @Test - void should_mark_sent_when_retry_succeeds() { + void should_mark_failed_without_calling_sender_when_policy_document_not_found_given() { // given PolicyNotificationTarget target = buildTarget(1L); User user = buildUser(1L, "iu@coming.com"); - PolicyDocument policyDocument = buildPolicyDocument(); given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) .willReturn(List.of(target)); given(userRepository.findById(1L)).willReturn(Optional.of(user)); - given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); - - // when - policyNotificationRetryScheduler.retryFailedNotifications(); - - // then - assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); - verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); - } - - @Test - void should_mark_failed_when_user_not_found() { - // given - PolicyNotificationTarget target = buildTarget(1L); - - given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) - .willReturn(List.of(target)); - given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); // when policyNotificationRetryScheduler.retryFailedNotifications(); @@ -133,47 +113,43 @@ void should_mark_failed_when_user_not_found() { // then assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); assertThat(target.getRetryCount()).isEqualTo(2); - verify(policyNoticeMailSender, never()).send(any(), any()); + verify(policyNotificationSender, never()).sendAndMark(any(), any(), any()); } @Test - void should_mark_failed_when_policy_document_not_found() { + void should_call_sender_with_found_user_and_policy_document_when_both_exist_given() { // given PolicyNotificationTarget target = buildTarget(1L); User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) .willReturn(List.of(target)); given(userRepository.findById(1L)).willReturn(Optional.of(user)); - given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); // when policyNotificationRetryScheduler.retryFailedNotifications(); // then - assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); - assertThat(target.getRetryCount()).isEqualTo(2); - verify(policyNoticeMailSender, never()).send(any(), any()); + verify(policyNotificationSender).sendAndMark(target, user, policyDocument); } @Test - void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception() { + void should_call_sender_with_null_user_when_user_not_found_but_policy_document_found_given() { // given PolicyNotificationTarget target = buildTarget(1L); - User user = buildUser(1L, "iu@coming.com"); PolicyDocument policyDocument = buildPolicyDocument(); given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) .willReturn(List.of(target)); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); - willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); // when policyNotificationRetryScheduler.retryFailedNotifications(); // then - assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); - assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java new file mode 100644 index 0000000..121a919 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -0,0 +1,133 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import java.time.LocalDate; +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; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationSenderTest { + + @InjectMocks + private PolicyNotificationSender policyNotificationSender; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .requiresReconsent(false) + .build(); + } + + private PolicyNotificationTarget buildTarget() { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_mark_failed_when_user_is_null_given() { + // given + PolicyNotificationTarget target = buildTarget(); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, null, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_user_has_no_email_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, null); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_sent_when_send_succeeds_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(target.getSentAt()).isNotNull(); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_send_throws_exception_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(1); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java index 1bf8e0e..1374404 100644 --- a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -22,6 +23,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; @ExtendWith(MockitoExtension.class) class PolicyServiceTest { @@ -68,7 +70,7 @@ void should_return_policy_response_when_valid_request_given() { PolicyDocument savedPolicyDocument = buildPolicyDocument(1L, request); given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); - given(policyDocumentRepository.save(any(PolicyDocument.class))).willReturn(savedPolicyDocument); + given(policyDocumentRepository.saveAndFlush(any(PolicyDocument.class))).willReturn(savedPolicyDocument); // when PolicyResponse result = policyService.registerPolicy(request); @@ -95,7 +97,23 @@ void should_throw_policy_version_duplicate_exception_when_same_type_and_version_ assertThatThrownBy(() -> policyService.registerPolicy(request)) .isInstanceOf(PolicyVersionDuplicateException.class) .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); - verify(policyDocumentRepository, never()).save(any(PolicyDocument.class)); + verify(policyDocumentRepository, never()).saveAndFlush(any(PolicyDocument.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_concurrent_insert_violates_unique_constraint() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + willThrow(new DataIntegrityViolationException("duplicate key")) + .given(policyDocumentRepository).saveAndFlush(any(PolicyDocument.class)); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); verify(eventPublisher, never()).publishEvent(any()); } } From 2c3aff1addc5440e843c7ea83481377239c0762f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 15:15:47 +0900 Subject: [PATCH 50/54] =?UTF-8?q?[chore]=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=EC=9D=84=20=EC=9C=84=ED=95=9C=20prod=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: application-prod.yaml에서 spring.mail.username/password를 필수 환경변수(MAIL_USERNAME/MAIL_PASSWORD)로 요구하도록 설정했으나 cd.yml의 배포 스크립트가 이를 be.env에 반영하지 않고 있었다. 기존 DISCORD_WEBHOOK_* 패턴과 동일하게 GitHub Secrets → be.env 전달 과정에 추가한다. 배포 전 GitHub 저장소 Secrets에 MAIL_USERNAME/MAIL_PASSWORD 등록 필요. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .github/workflows/cd.yml | 6 +++++- src/main/resources/application-prod.yaml | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7606011..0c44771 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -67,12 +67,14 @@ jobs: V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }} V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }} V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }} + V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: 2222 - envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL + envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD script: | echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin @@ -98,6 +100,8 @@ jobs: printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env + printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env + printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env chmod +x ~/scripts/deploy.sh bash ~/scripts/deploy.sh diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9640fc2..7b55b7b 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -2,4 +2,9 @@ discord: webhook: 5xx-url: ${DISCORD_WEBHOOK_5XX_URL} 4xx-url: ${DISCORD_WEBHOOK_4XX_URL} - inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} \ No newline at end of file + inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} + +spring: + mail: + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} \ No newline at end of file From 623a633ead992853e2c2226658c5c790a9edaf94 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 16:01:42 +0900 Subject: [PATCH 51/54] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=20=EC=82=BD=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 발신자가 이메일 주소로만 노출되던 것을 "커밍" 표시 이름으로 변경 - 정책 변경 안내 메일 템플릿에 커밍 로고를 인라인(CID) 첨부로 삽입 - 본문 표기를 "Coming"에서 "커밍"으로, 안내 링크 도메인을 comingg.com으로 통일 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../policy/mail/PolicyNoticeMailSender.java | 15 +++++++++++---- src/main/resources/mail-assets/logo.png | Bin 0 -> 24707 bytes .../templates/mail/policy-change-notice.html | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 src/main/resources/mail-assets/logo.png diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java index e725d8d..80108a3 100644 --- a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -4,8 +4,10 @@ import com.Coming.Backend.policy.entity.PolicyType; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; @@ -17,6 +19,9 @@ public class PolicyNoticeMailSender { private static final String TEMPLATE_NAME = "mail/policy-change-notice"; + private static final String FROM_NAME = "커밍"; + private static final String LOGO_CONTENT_ID = "coming-logo"; + private static final String LOGO_PATH = "mail-assets/logo.png"; private final JavaMailSender javaMailSender; private final TemplateEngine templateEngine; @@ -32,19 +37,20 @@ public class PolicyNoticeMailSender { public void send(String toEmail, PolicyDocument policyDocument) { try { MimeMessage message = javaMailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setTo(toEmail); - helper.setFrom(fromAddress); + helper.setFrom(fromAddress, FROM_NAME); helper.setSubject(buildSubject(policyDocument)); helper.setText(renderHtml(policyDocument), true); + helper.addInline(LOGO_CONTENT_ID, new ClassPathResource(LOGO_PATH)); javaMailSender.send(message); - } catch (MessagingException e) { + } catch (MessagingException | UnsupportedEncodingException e) { throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e); } } private String buildSubject(PolicyDocument policyDocument) { - return "[Coming] " + labelOf(policyDocument.getType()) + " 변경 안내"; + return "[커밍] " + labelOf(policyDocument.getType()) + " 변경 안내"; } private String renderHtml(PolicyDocument policyDocument) { @@ -56,6 +62,7 @@ private String renderHtml(PolicyDocument policyDocument) { context.setVariable("detailUrl", policyDocument.getDetailUrl()); context.setVariable("requiresReconsent", policyDocument.isRequiresReconsent()); context.setVariable("fromAddress", fromAddress); + context.setVariable("logoContentId", LOGO_CONTENT_ID); return templateEngine.process(TEMPLATE_NAME, context); } diff --git a/src/main/resources/mail-assets/logo.png b/src/main/resources/mail-assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..94ec3917b967431a52340eb66529f21d6e96bf3d GIT binary patch literal 24707 zcmeEu`9IWO`1X732}8&hV@b9o*|Q8G$-Zx)B1?9%FEhwa2vK&Do$M-%J(0-1Ga|B% zeK2O8x6k+a{t?gX_0$i%%*;9GeV_YW_jOq3BtNM*EygJ1!@&zWc}pW5;=455YUVkdVx8~}Q%Ix0XFX(7 z_^=i)+nX~XYhF%I7#qNyzJ+_4Ho&i# zQLdVq?%LESO*h>^hV0{FrNEXEQjQpnvMR%_<}=6f!+zOdEwc5w|0ozfjd;fFbTt!) zO0Qh4oS@_`@B=N&tE6XVn0@x!@zzaF!F1n%cQER%MOUn^(bb;vySjQeyG;7eY9*aW zuj!dM1JTa`)y_kYt zbgXzF$?{UcRJZnoG{hLm|C$;&3dl|UwLa>*8~RW83u%}L$6OaCvEg-kCyKr&irdrL zWf89f`?im^V-uo(aYGUqbJ}M1e~~C!b{@8d+3Ucqu zU5;(Y0b{1!O7U)uUZ7F3I@G%y-1aHfcZ{TiDA#cz4Hcr^ny?Wq7e@`eXtCte-mNz4 zU{g5^1SppyNV?!>*H}34?Biui&v=SY+d9(**yx!atynq({5Fn`rKnoRsoRVfSCG#E z59Kg4EI7uB0nff|tQ_*haJV^Rku^`VhYWddnjEO039Iw7=r`y+p(>N{}eq=Ps@i){aT6)w90R2}fYqRtfeV*9+>u zgX29IZ+=fa`1b;CxMT;&Wc%FvpSLzOu?(MU)eQ}4Oo&X`cswkq2PckZdEyKnuuQFNBSi*W;QBVo;8`}A)lB295Y%4m`4H?$kmir{SC?c$L%*=n-NI#*z0Tc7R&-R?(75<^LUnqDvk8Lf zVb9`1S0`PEG?p#9M7xUPru19T|FS2ps>6-`*wN4=B12Yw=YN?Met+AzW)Zf86W=*_*^g3Mr2A;AWnBpIve0C;lEm7c=FPgKYAe*GSn22iz(g#4udtJ_gxp42 z88`dv$;Q0le9R~bq)PfEX33e5LW$mHyMJ!#B=C@i6b{F7Wu_yZ78Y0)A6JxXi-)%X zhIgXY*PuIludu{K{KL0n*{)71;Qb1CV)R2nuxUd#(>o5;L#A<^T!<9Mgu_hpk0JY=X2>t5-yMk2^9W;9#< zFkgGV(GNacxej8DjnA95eLuZm#`a_mC;>`pI&*t45mxi1V4`=!)?n~sY4}fKnH16t z;-!@g;!#-~Ph*GpFOerr>s_yTVxE8~8_>P;J3Y7ZCGNUnm0A~yF^RIP`=V#}&Us;i z(GRY1`$1-EgTKVr6u-M&^W(wssN!}UEIA{~%q%PL^5jQt94}Y5IEbO-Z<3mN+?QBsMDw`^P*RV$+6&{Yv z_~bxg?t4Orqk^KC#G9TpY`|j{QrnZhAMl8!sC?lZO2W48+%7qX$lP0ev0tTpppUD? zKxkonp6G+np)0x-1>s!$*T=aT1;~b(6cGS1?e;fpQXdLYM-)in0DOcD%M%$A)v^y~ zBX^MKSvv8s+|&#nh3(Rmg)z+!_xqf8#4L~&0M+Kc+lblOYs5+x8>T)%*^eNtTw`vdUD#;m=FRJ5hzIr^VFY zwEO#icGuDIMo|M_w22|stEk{Oqq{uyJ@jbnt9@M|H2LT1zC3#?%q-RRE79WF{HtlJ&r2lj>L}_PzwdVvv^~TE7 zJOa@zb}HOe_4i-+4;N>k&j=x_kYt%;BgYf@gmvcaF&YJJmk(K5$f3ax^^%?0X`uEZ z*Ja8)Z>i{zEbWT-v4KG+xQ9j*6DU#B!SKhsi8btlx&e7U@0u)fk6rP+_YS{ydEVkvP=28FJ-M-8zAjwHV!A}2I``nzhvb%?aMnDvb!s-gQ-=AUMoLM^?A2p*l z**@{umFGt9fv0j0c1P9LRxyc<%6mEzD@G72;oC3gI2jp*CCk)H&VH8Dh{adP8i@+; zSjTeE5RSRZs<#K(-KXgo<(|=g2?wqCd@^SAoTvQg1;sBa7xhI9QwPk@7QZy8gL1L4 z`)-){9`o+#yVpRd^o_Fc8#kbLu9ySK3qfd?t9JA-qmy$9VmaXLi*G$KdJ#?D zcFVd}nB37B*9qN;(&Wr&3oSPWu~rL#+oY;BF*g@Do=-hM%kh}EO$s5 zB8$udqxg+{eleeHTq5stPMoHoa2LS?j>$F*{)*Z-ELZCkcQbx^uOQnY3XLd1FM7u! zlrg$q@Wnq}#;Ln?(l$~J_CvpnNOs9=jmL-r@8Wne8TJi3GPuP2a{x~w5H&s!)jF-L zkaB0R&AjzNTUQP{MLGNb=@8`jCtEW`rpU?V!g8jV@o%CK>D1?d|0F!*V_F{ zuOc?#CUiJy#A-qAEvsmLNiZa(Vj_Y=7{8_)qY;WKZbBhz@1y2JJ{d~5&ZttJTJvjb zN@t*VeXzb$oC&n8FthVrp(l<67Z~YBpU$n*vVdYh#=}Yd8CI>IKCS$~MC8dkKc#~8 zDPWjuR_3K=DNqivEjGYj#h48b4@A0#{Iz5gn>G%f8T)-}g+sB;?jOj_D7#Q8<|PYe zx>MNU{4AZkoh_%fm$7?tNIE@}$H7>&de-)jEk^J{);n;u6Fjj?xYpQKQX(dm|f{@R2cW-osf&YbYbI>s=bQM zs}>3G>>*jBjzu|F34M7Fv3IMM#v>37o+Nr|ARaZAC67ju6}gBZc}DqhjDo1!GPS4V z+FAU!SlMM{%LVNhD|H3J^AcByS@7~4_-r461zOR+Ocla}_UA_VT|^LT7iD~6-Wd-h zUYvr^m*{I@Y&@F*5rNIDv6q}YCsu3>(!XD*vnWq42{5NUM5D+BN6As=hjEg2H7`ya zO>}6-)d2a4kUWs%R_2Cb;3d@U7w5+`xIMbe?QAF1-z~HB*wA9&P$wKi4)EJ*DB!Hu zV#4}p3X89Dy8ZjM)?WCi8nfuAs4-7T=>IJiX_zBWY!<2^PyHlWsa@`p)$DAy(==G42yr<4NG@wH;& zoyOHhWAUksp0_i}p>q>kS=N-cK2b*%HrPM`9CU$UJ|b_|B4YD;<>ObcRK``s$j@l7 z2;!wDPMhDD@XrP#S^s#M2xG(3%u0Phrg>%ik`&H-vf%PG?d&M^|BI^lSV@qmZD<+q zz5Q;BLpHcTF_*-4s^iE-vmJ9{hlSY+y8Aw>#Pw|~icpi(ZnEwpNS{8&9a%yNea!@H z(BD2KA({*4=rLI^L2ItBA43HWB{d*}axtcn@Z7h4qxv2cEXnvT7s2t?RP3MK;a~h zJhm6aR`~v1!wa50`tfe{Ad7~uQDeexe6*M)rLt!ei5fo=lfpq1LzCO`+?=*wo2AeX zvgT+XOv;7SDel%8W_&|K6@OLiHf@p{(-bbmRRg-3X*|_(^9&W_OL4My8HaF2A)?s% zVz(yIaNch^nfH|pbI5d^`8e-C!`d5So1ihc3X+pm37Ajjxcz7Y{n{Max}_+S7aYeU zHKW;uGe-->Nm{%Q3xG)i3`X01imq{30?|@mGqC|Jo+t=qJFx%F`R7yh)hS0Q;$|jf zBHGRSVu2A)?$#G6iQ3(NJ9&NUu^h3ktj93{*gx>?GjY-7%W};4odPLyUwl4%X zEV0)C)$!-H!iub~;VL1^udthYp{g098!K$ z%aAC+kn82CB#hFGc)&dsB@wkoo>zjU1ib444^vCa&R)$a3Hbl5hQ4$5w`)saxi%xZ z-_MO`-1P^n<0?j;DQ&he;e`POQd?QhZz>JuBu12DLMuSGvv{|Ikwa$22X+}lRZoU{ zikx3`**|jglD#S+>f&T3d+NS?)CvNFct(47fyAoCwIPZz?TiBrindv05c;_cD6K3o z6={y8CtwDgy>&y(zq$TLE@Y+mM(SI8ys7E_ul>#L4vkaeocpV$%)NqunzzthuTGR6 z5+#TUrC2b@K%AV{;vTsLHKusqK z{<&f$+osxA9QWPo$f*5E%&~n13q78|&@ub6|5lf{6q7USo^LUVmre&%t13T0W52boSV2@FN>}OrtZ2`-9>u1`POQ5Y*3#LY=M~o14A8#|BOeY_WQ; z)&T{%hc_kAD6#d?8f~koV$YxT16-zWj+?&9G2#b_Ds-Q!C%biTXetz1jI)R(ijkyn z-6Kvy>yZ~(W1}*9|B+UU%%%E;C4Kc;qcK^xQ;byktMv>U#WeQfv|7$5E$xLsT@8eD z>xOsF&CosbvFy?-gy2v4_Eht@pWh_yVw_Gg{-sB+djYkbaXzMty($7w#Kj|{>xVyn zuZB+uHw3--s+srV#l!91?X~u?Y1${zeM5IFyI@4ob4H(EqEF~`JasQ#`}1fKqoN!L zsZofGvL~jPnZjyQvz&WOY)GrBmvcWuJN{lEh}~71z}XQI{k-9e!Dp&$b=?h{_2qXY z$10G%oz{$g?A*}ns0Q1j@$^(NvvC=*sF;2&qW8p{BFB9kR0x2`EE5%gmWkopsIVK! z8ldWPyLKJ@02}4vFE!Y79hZB@o1J zuTfW)zU1|(^Rnf9Fx{VcJIRda>Zyi*DRkJTZ^}F$98y}H1sZ)}<8>gWP|DxmQ<-P1 zdU9yN*!unKj3yEV&{FNDGz?7$F{S!rDHTN;K`<#aJW{@tr9SfGxJ*qvRR&aw+j6aB zTxa|;Ov4We?bf=a1H9|s3jIO5lC8e>5;A6RGzMvAj zam>(iE9uH!x`#9l`cjfANakdfln{#oUI?}k4ab#!ILTk@B)~h2y=o26D6hHk?_J#* zkIpfZlT`G4X)Q-%<5Y;vhZWYIdG6cGx4-^jgAK?;lGoD?fP=N!xf&e z5W*}#0~MKh7R*Nfg#{l5k6V^iWy>+QFXWDWc49V3bIgWi;S{4=i-{iq?YGNVVJ?1e zue(MII2$cq z$qZS1{Lu|7Rkn^R=s15Uipv)Bn_r%fs20h2!FrFiKu^RsGYk_&w9A(x-8MIVb&+u$ z8psU>Lk8$ePFlj-#nj{};PAn>#?!UD3)`EI9I?cv2Do2hZ=Pgpg2Gd!QsD!x?@>(K zpW6~*<|q+MQg?%`Z~Hs_3wO!z&jq8$PciiyQVGn>I z8?MCGe4e$!Mm6P(eMb4&;Zz0a_k@vFpZ2lsKwULqF*<8cBn@4y&$|JyS%GTYwlt7W z1_Krc;O^!3n`U!1qdOZ@eUJ2j%*}7ZpDj)IEtu|I$S_*+w|_Kz4M(<)6rW;nOxZGC zFUD1+s1BOQ7Nhd+{^k7M1+O*m*;d~CeVLYM=-zrk3p&L>MsS_$1D)4!K*GJ!1`W46 zqxxurEpM^?Yn$DU(P<59SS1MfUF;ojHFLCwq<6u9cb73y7q`zN3p`=-LI#3crF`Z% zakLky_ZKY$v_AU3k(@e@a~utQbh;$0GgKpLq4}*>6FyFRA&<-wrz8*qu6Gu&hF=l{ z`*;d;kxFz-43rSPVLXj6 z+#Ar5>|xb@A5je)au2@sz8Y{7S0naq8j+bW{ai;JZ_yLEegHCAf~gkE07eHEA;c7% zu{$8cSa!9L=F!f3IEiNp8@TRIXKt$B>0w2;aF9HHjq~}`5{3R7m!4d5-XGs$hdAI% zGA%ap7REu+-0pic!bv~A{8?|pDJ^2zY0WUCVKMu&Q+R;Q#ZXDWQ0&=+W~f|jpA7d~ z8pK>W>gDgI>@txLGY5aIIFlGb$Bhoi_^an%tzev9@^D`Pw7iD+`3KY_JzAu_I{W^( z+mWMzn^qylBpUCA{!~mvML0BW{=3IVowf%wsuK3DRz$*)f&0e7XK%t+RgEw+ohF=q zT*nk9oiJd3?C#(1)o<>Rf@ryfDt=-_0dtLSo9wa2D^_;4O!vl%8s$oQ*Oz*#eRxfP z00wbVw=WQ#UMGPM7B=JX>nTf*AYX%|#d;TGb~2qHngXT`>)%Sx-ZwRnx&gGS%8^wO zh!yPV(b~{}j&zb-BDJDOQjyD~l4#yJhY1MeLB5!G_9-E`^_M{Xso#s}$By)u7r!DA zm5$8zjBY_2W^9bQtbLXjDlYhq{Q<+nCyJ7$RD_3gUnX;lzJsScbtKn+Z(~2ZARut9 zawihu_f`qR;#Em~OXQC|aBuGWcg=p5jNB&b_M~5dbJPf6H_chbr#|mzy{MOAn$0J9 z%H&O&Dz`sR?Yj_BL*jhbTUxiOB?a@ujl6eS{!op!=#msdZcr;J7-n)kX$w+L$-Q?J1X$m1<^^Bag0h~$CGFn)$Za5K1K(dy zG*a9}9_o|!p*RiH>SNTu-f%$Nsd2yBz$GxzuUM^`L@5jve;T*NNGizTChA0An*g zO>IL49AvC2*+2)m6hGX`5P{0;h0ZzI9G5V>i0t%ZKvt7mA6M&lN!zcV@y>!)Sz$d| zoukg9d8CDn$~N;tAAPsTOlpawR{V6$%NY9*3>ZMjzixGpvR?dR6X*R;mx?TP_GOSw zbQ!q5D*wN|0N_YvI3`$$Nk^*y7}Jr~@l#Cv+&Z9jLN5ylS3AbWB2k9wx$j6Njibx( zhXSX-&Q=G;O+xdD@&3dJe7Xe5&pUIrqy(ht`F^Fv(<-VKJN=7Sn7G9!@(diL)*?6^ zqMy`hkJtSz`*^}+tET3$q41|&z9h_QtZ?zDQTSCrlnWH|i3l zfrc4RWk_K%C$4i|4iwYfESZLeK0U{0Rjiv>);*gP7**qcdhxb{CJIghO?5V{?cP6A zk$;+7uTU12z0G#%*vOYF!m9l@LDT*`)*F38e77w){2UF9Sdi@u6^m5A9&$_3XF1tj z0T=46+NOhYa(PgYBj&7ns-X9}jV>t+19iLY(!BJ^rc(H!RR|)8FalBlM)oK3&}{JU}mg8Wsp&ur#-E(J#nJCoB^m5t(JV z=ZS$%tJ`Oo!*aLoGo+7myb#U`?RzG074gXF?tou+?@PUHRw-{PHicZZYxwns7}Zp$5`NOVm0yqo~#iL*68`5~{UeT#8%i43?CLXk457A3gO+ zom3hXjP3fvu~t!<$MrHVvBZF8xErx8CAbZWAn~BRsbG+9gHCJ>k;tu4FpK9k_uv5# zTSxO0i27)#*XaoS1?6a82y58I{E0LS&lONgMx-!6sz39bI0;gA3}q+2DIHwpg@ziP zde>TCT(z!V+L+q>b`7tad>%I;avIg}BWA=J%dKRoK(%fR&Q2yp78T!U4l5Fo1l}tq zc>US&1$RL)=z1F>OYzNb`&fYV_oOc$4uWIqUC1@No!lojHntyr@`Rd@#CglCJbr`) z#L&F58dyB&_ecU8?s#Nyf-gZyRO;-k3iK#!4n5Ot?#EK3Lf4&1FbIJwgy9UV&iy{H z3B&Kn>UujZMH{Pk@c|kdDrX-&vVPeKL5b9xPxlhVg~AzKf&~A~#EhiN!GgWwfBz;k zC8UX#{mb;yxSjZ^zMHIl^JXA-L7VpOLo9LGKHDa}T5%W)_*n_uGYVHG#Y*7^D@tXk zoyNgzkl#iM$k@+HhmFv(0o5t|B7|=Yc-kOwzk!jHN}Yvl4p^VDL*8chyBp83#D3+N z%Hj!A@M%-_jpfcbkcqQW%}n@wL`HuTMD7hWGRl#2$v}1O&Ri=Xtd(nON@*e-n)WXi{75Ays?5FU}$u67X|-JhhKe5 z5~{f?>9HJsA0O&)^4!I^LzdS~{^;EiWlW2Ps*wIQeE#_3V_ea(hNNAdco$jw02Rw* z$m}X6s?T0oQiRa{(0hI??N?v;5)M4WKy)d*=?Q1@_f=H$(h#7q`E~vj1HB@sT_<|A zh(!|M_!O&4yySx=)*YYR8oRD#QRmM=A_Jb@rSO6T4eJIy?&K4J`@su6m7s)3$Ij$U z?*HI$^z&P_^=6QL0wv5sE)3VxSkDqJ=PFq*#I3-+>(>gL$fcT2+g?WRk?P$bf7(nG z4jhu-rB!4@-U6s;$0!bS%dn5~5JU4)heNe(6_tFOP2KH}`Kusv)C3G*#oX7mg)_<% z*OzeJzgsuhG`|n|@ftq-oOxQcCo{*n*1$#$rt?*luc;>gTE2DTo(wG>`0(_MM&py+ zzQU#STGc0|L?zD@nFC4tqa1h*w-ML&?Mk}^vLz<(Gzec7F?&OJf=eRB^~TD;#7ju+ z1B*FI0xgQU>kNfy*Oryg4_FccIEs`A|K8=!-2$rvY$?F%Fv?Ra9;VRhnS;cq<*}9%tW8}3#}&4MQm$_1L^DTq!t1jXT#Ih^ z-)>0x`=&fo+&3Z`&(b~!Jibq`lv%;-el_64eh|EVVnitRW_n^`jKH_xl*?!QDJ!&9 zu~GP?HJ~My8mU(Lc)8r;(n!d>njg>2_Oj@GtHaUIj$CrLM?0@oSA-MRe#ut(jAV@y zf%*f48=*0Gg;3=iM}ieq!CU&`j}98@E52Ks9kJBbT)c_91bj?ClB0*LJzV^7>(77( zxeOyd>2GUr0^Gd1>f5F6NY+tPlfHgPUNEmw|ApS*o!RXKxt+Rp;owca!3#6p!{AWO z^?ET0^x~dtPV~a!-KQrowjorN8j3@zeHX!`7`KUr&A5l{AK2!A2RmF)O@}4aZ?{;M z2@ibszO2kUr%omau%1t&M$ynK4`y;2j9L86j$Th&Gk>-x$Wr0}MW5pAQYhh#g$~G+ zH$__TcfgX-9+PjB;T#GU2DgCcUBIsQdKY~;N`FF-8F12@c}l#b`aFv{(7KbFyvZEf z!#DiV8qd`C-s7G7Jsc#tDOO5uQ0Jz7Gbo){ZXcZDDe}&3^3^W$ERuE?YVyYuJFT|I z!&ud3mF^iEsZtDd8&gUki8In;eFlzP@S6>Y0>f0;TiiL}0|Bhh*@HsEO7^NR#u zE*uwf$iB^~y7|{j?d$+)hA1_Q&OHn{ZR(Q7vEa?>Va0Y6xZ{EiWvkEz^6)Alnnz1w z!L>!YA@^#Z>p%eC4gk7X5GeKffhZ`VfACTJ1dL9A(T}#Qg?Fo)xGT??x~!4V$d-mJ z`as6FO#GZjfs_M`_ocwms0qLR z?4&lcufD$@2IgRvB1y9aJBQj>wOay`;eNtKeAN1N8pKQ5SYkzEWsf5|$$>(SUT`?g zD>URw0obUW$NK4n@!{&;6azXuU>+L6R!DUVS`K&n#jn>YwV}NWmZE=e90+Ul_^!#y zN6v!;3n}72)ns(zNLmueDeSuZ80V*3N7og%OAVmmoYm)tZ=&Epi$!fn2Z!vDUO@SI zFHX+rR05ghD%;A2Pu5zSv<~VDa-zR+KMi9uwTgjOzKH*9!hR$UTCOk&o!!(-j|UEs z;)Hq6XL|K7*uMMbE~H_|IrrdunhT>NK~QAjSC({Y;)z_L?{aq};SU?rcSRtl-%YrDW&$x@w0dM!MGN8CMjwWw?u8 zI^`GxNLL;ZLvsHq%Y4O$^jBaKrN7~q*{VKE{^N=xjGK~o!s9_7qhbSgS$0CO#DY_H zrkrL89TRD`+l>fzGf5NBy7Jn}OH0~UQb z3i!nUftbdjA7Cu{O)|uK`!}5c1w!%2ji@A{lV2+WB{Om=gm}*miukW=thCx^h>l~J zXRlSjyrNdYkEmX$rd$^WO#^n2CQ{O_+W!UHW{{6O-5?H*rLZKfnEn`p&?9T!=Rpn) zcOJVPtqp#mU>_1pKfMIJae{}C+@OQonhZNWKNKG+aIgo7sH6ePDQAsDMNqx>UKs6S`71Q_HDZIH@H?Qj*y5Vi(6dGN z%3pRU8w0(lZqUiETrN?ofnaDyDX#mr>>6X00X;3;545zo>epPxp9vTy#c^C;FJ0=V z<0r4SpOXH949|j5LdHR}sWDIfWd?aQ`4Y%jfG~aAa_P&SFF6&i?fHgGG?XI9tZki% zuI7G1sa`dZ;qEcpKos)h>}RiD>L>HrAOi?C@CC>Ml9Ae9YtGdZ-+oj=c()v}gPx9g zA4J-&3=*_xE_0G*7#!Qqd?W93W5 zlPAmo>iW+cH2YnutY?+LW~?>-1wGPEgYhQl-GK5bB{B+h30l3CB2FAc$#V#=T*q6| z(AB35MF3Nd;4lWK!JfSW!+fu*eIwx{y7czuLY<9w5*?X9GG6v^MKEdwOm)EJNbf@L zGvMErpKcM)_;bnz7pVH6r@` z5~?TP(L)yWE|m&Cfz&sbI}z>U?>`uUfeMd3w`nwWvh8^?mydzYIu+l3Y@zmjZZs1- zmxhTo`tO7Vx3yX=KudB5U2WI6{Ak(l{BJ&&6jc0_jB`T_^1Ta2E3GGVl!p;;_bS}k zaslgrM%e_;?AKP~UShbeKmXU-N#VQIUzqVoF*nGgNwW3`a9cMX)jg1RIC;H$NW8S! zSjYrwIqmH#JX`vCJx3U5Fn<(y>M7U&sGd^eCK@Vza2@Fbt^#D=fOmoMhF&DX`lNlc z0kk(dh?q0^0BB|bvCDGt)5ljJI2+u@H9xtEU^_6+9xlQ}0lMX=-#^3xJh0#Tw=sM} zU`XXEuv?$5+C=ZRoA{GdR)Kgc`jZ(Zd9(WN?3qNv;xnvro>q+7w9Q4Azq*2*>RZHl zl0JxvQT!i!wlV3$gcLX6Q1|6_5T72nrGoHRE8gLgb9GR~Bs7dg=#3Qc`7lGf?OD{n z_O&Sf7ni1+I_*B^ii9Vv$A=qFW8D|HA`w^xHXxB+k;@@kw)1TMj;uVWODErFQ!^`g zbV!LLM#H`i4z2u;5%iqqy|00P*lw?TDQdo5vAFl<5|twUz%mLr_Nv!215L0|!Onjk zIgNgoSFJVx?UM1oC$A)8=RSyfB!b#OWbK+kVL7&4U{)!&de=W}snCJVWp2B6vTm$!?s*Pw#;L^vg1-JKtZUyLV+t<~*8YY+vZTgx|j!gZMW%%s=o9nS} z*$D4p04RD_5_|2#Yed6)yl?i~%z(M}gEh9#2G@^nU!E1#HR1p0g6u!10Jh<&T^sP= zJOi~0B-j40j=)w)9OP-aVX6$QVC2YSIS8~UC7fiP_i}#Zdj5t32uiVAV~h#Cl??~k zH^JpaPM5}w7|iFiW|Bw`4}ipIVmhPe{x&wlhYzGR&}c$GtO)FchWwVnDmaV zN^U-6t20}6`rUclGY>{WZZeUfZ7%SEy2V$Y`fh_TtEd~K-b$UiRCI~+B43-jEe2H8<`;^Gbs>iLF*lnZ9_wW&* z85G-=!f${S=CK;_9AS`0;CVFAkmKrdZ?x}##_G67@8 z@E%oseTvCpr*+xyV=gf;Xm73@s5Qo(t;!F36>{% zOAzF{i6D>U3YUcGK_3l|EZqbvy^hTZBVS?GdUPbVYTrrct{~b;+HC9O9vgr{b|X<3 zG{68GrG|R~G>F!{5WEf>PzOco30$nTxOrt<0LP_qQ{MxE!c<$>|9HPyK96(i^#RGI zE_lnSU&D#h`;k=OCE(l3k>rnt{zg#m?^b*ktrLF7G4KQ2&C*xpF=&enu*>@Miq17l$EKx>Kh99=hanJbxFK{400(ukkI(a z@A^W&9&dpfk_RgQo*hc(Iuqvqs2ESdY{BmT<}m|#T(S>;X>WW0B4eQ2l?Vqk%qoFe zk?e434}l}6*V;G2LOAX3nYHhM2k{e!A} zOLy>UQX=}XF`G44yh&XcNWAnA8>fgAH6Gx0bC943VjDT)Ruc6S!9hA)!b48h;VuEsd(nz<0&0*j;HYatkB@X+yu z$Y41^(M9>>C;gVuq>CgeCJMVLdkqgH6;1d04fG`QF|^>O&UL@={slxL;g}i@ht|jX zdolj>*|4rVAmglOxdlG&<>II>$noTg!`tA3OKu)fD*7$JKXT%_qBhKo%ei;^0#}nG zhWV|j!3CKpxrZ#2C-$^GX$&_!9MF5Y9#CATOo0F@{lD$wo$BN^K){jvI|cRuDq}>K zJV8u3ylK&+YP0@Lbh7@lz*+up^4OJwM8U#^FM!-C8P`)DG)Ayh7c8Y$Jf3ldW#gHb}RLaOg_?(YxZ)3>zHn5i#<8X6A2dJ-wy_|Wh%;CJBg^U|=#Ia} z;BjGosGnOUvY^{WA4}*K{`nrX0#@_S1JHuSk5pCfy7d%JDtQE^%Zt)Hs+W0zMrj3k z&g3o1f1d6YRKLpFJCzC^awGpmV~^D9pi?LDcS0q7L9jFo{!=l*`2FlyqA>MY3>b`o zk|)j{5BE!27!&ai@d6##X-Z53?)XW`T70A6qWmOK9`vbrWD*{PH>YM4&TE* zUY0@ge@Fq56&mW;D9J1z3;+{~5ky{U z4TU88-)VervwKj0si$!9tn|I!ps$~1TRgtQo?jZsFmR%QCaVkC!hMn!rSL28U-E&` z&*}%dq3)bT{J3&1Y>XqT+L{7kiAVmZ0wpGPY%?*9i7La3?(shcy9VX@NhBfea2ywH zpN!<2Hwr~uLi%902V0$R)kBt>gdS4FufVsIF%y}qtWIufV>vZ6nkZ(@*|5echTs z#^OJXK1}a~aT8dvbFFfx@6=A|q4}?h@|z%$YKxn;;C%>rCPjT}q+`DZx2Qt?t~+?V z%jWXhAA~5zaFgL(pYb9k&;N5h@CY!Aq38ZN%6`TiU9U(#Q7qBe#K zC^y~$%b9VIY;TW<80Ct^*^_;=Qig=CcbpI`sR$Tq@FanBwJQvx=8pk+%Co4+9B(82 z+zJuU@IZ%9_N1>6P8q>G3^T1v?AtuH4A?XQZjf<;HN9vO8@Vf7B4BR)j8{1mU#2+N zAq-~goJf{K(k-K@Y{7TzS)=nH%a1lF`}M&;vS&Y9PIz5RIq^3Gm_^MK=d==(+0+a^ zfGGP6ycU;wd~$F8vaZy>+DI8+Q1Tn4AG(qH8j-=ZZxFXi=b!nQQx%k>%?5`i{lJEz zMEagG@KuMJA(MB%u(6dy-?W<=T8xm^7J`E_WLLkf9L{)EX`P8tsr;)z$7ZT2yK7!@ z7^-zaHZfgR@H~1{KCgJ)ULVw+9|_L!14Ws4H{^w*j2g%!J_-0bn6VJr40^8}S>}{s z$e+Hn&^U1lN-D-M6?~_j$CL|F;B6h<-+~aT_djaPBEblDAQ-e(*lCZ3`>(l0)P19&E}iFGUAa}Zib8~IfCVIGJclYUG~d@ z;{D44DZRvuf3|IhXZi-{<5GrKM@SXv@^#}+`)eXMELmoeq*4}e@uopHDp9>-GwnDDf!nzs6O4qL4H#T2-Srw&^>kn z(}$Q>L2T`ykMB0O9B)nM))`vd__X|sAqrtTH@V4nfUN}fKYsmj5T(32G8O@U%33qD ztiI4i#u?5)2>J04AgF7BECBbiao)D3B_nphZ`qR6*i=~HFAxZtDo+qzmFvF!9=xyf zSR^Fv6p58}c?H&4f+QVIgDspZ6dm7y`frUh@1MJ>R5gHx@Fy zn%a8@T!|xnuR%glUEH-H#Z-<<0IWWUB7j8`(fBN*C4D7H$k_DfAOfc#r z21m(QPS7A+>9Lsi0oA{JIMtU`@Iq(Xm4|#eRf8u41_x~qY!ttCwNu|WzCC9Zc@2@Q zc^zaXSdD_=X_p4)igyX=32Fb0#7!J0JMRlVDiE80aYmhGzt@+sKPfVIYdGS{^)~@x zJL@WFg!^HF1ijs$=H2KLHHjm3KVoDD06M>J@^_aC6%KHFi6xW}SM#nT&(ScuvLMuD z$A{j-*_7ZV0+**k_|D}8jks0}l$tI%LSpULhYY{FdK*S$RcxHYZp4qwu*CXeoPq6e zlEyrVU{N>pvegVoS}mm{V9=^0$XZSp3Ox4&a;u^Y>}J8{&sYm)(*&a#xbiYikpBm6F^}0dg?* zY?3LQ$Dzz}qg!5sZe$?3BkwnKgh`CQ@Ui?CkG^jIrWBj7e**aPA3qR%>2V9kgWxEA z*=7~oG1qlX=iy4uS-bDUlJ>LGrfiT!>0&ZPS>nNQgaQs?CiAd&FZHjP$-~o~TIs+i zgTbp&fWppuj2j9OPjWO^=H<%#&QG7)7aI@WqdGHX-7>@yBen@!^W~K~53Mc_$dx?q zX7~(^6a5r5!N+7Q+kbHKcGb1f&y1_*PoI)~&EdK`Gg1ZOe+vJbqf>J!NzIlwCeV+m zPG4~oV!)T~k*8oHgk0c}JFtFOci?etKQEDQJ|hK%*yRHvi5{t9iD~&U#hrUmiJr^- z&l>3MSffZdG8%xcc0?)se_ZzAD7hi+_l9|-oFSLi$jpCyl}JQPC-tV>;*k+Va~i&E4FhU_43QXz{+m4LqGU(#r62S&+>#sQ|mglYj%2P%vTH zt5H*5*;VApOXL|qOy=3|R%+|TY=5X~UcIl#p(Q;G>KvSlFwEBco0c@Se{o>H)3D!! zQH1a(1RD}^gsEKO zwaS~Sn+R@wO?vA+RYumeum4dE)!T3=7`O>Oy!J!s3S6TO^fIXD?rXRL2U!vH> z5C4{Yqf!TK6&eB#iNFrpN(e@`GgKRFB;wx>##eN2n(MaM?HTK}wg!W@P!Pb|!my%- zwO`+q2l}zh`^Ym5Hx}McUl^mVgI#)@VfL&dnTV!O>oEqyNoq-Fm?-`od+Z4fZgpG( zJJ!OcX!#SO9fgpkHq7-Nlv*~i*Xo$*fl}J4Lcy!NFz&O9Ghd(9rS2bzeewzEb3q9Z zFJdHmPpy}V@TP6_)xV%g6zin;>1;YAn$YaQ1&Sn1hmL{6XWc6=oPmHC+>yqHD&0ls zHuzlywfeS7;2~&Hk+{W;JR7WVgJ9hfqX->ZOd9X@tS(Lu9-6-o)mO9sxjyRm52s3d z`(bDGuo)$xZ`gv3=%AR-!uobY=&Wb{nIew$2`EIgi=1wuz&7~d%tMzpn4(s(3kb|p zx}&@2FADZ6nCo|bvl;}Y6GbCj_YGeYl+;Q~GhYs6du6A+?+XTn_J0FfT3>dF?#u2u zuz|RTRSv9b@yBp6F{?HHJ_C?be7T-NI8vi?^J97EV9)*gl3-)w`U6={%oY-CfbQ!x zlURE~A8nm4p<2$=CdBQvRSC0E+egEadAJiNvZdD2%3|uG%-^SF6(hx;c#bB#?1KSW zNmvRHINvcjA6@kMgkBN1+}qY*$e;@hp6rCtUZ`F+6HPF2R{v=N0^VO?m6d1rfQN0fqDneug>%rS=mUSc>Lr& zq{!sgZ=KS&+^x{(yu8C%)h+;8P$4;L7|`p{{Mg8WILAcIUkyY7#A5z@nmALlB*E_; zRVw4D30++P(J_j%B(f!~;6uL9r~&5K8w}pDMn|u?A9RAhC@{x(^DNW2C=G!rfE&er zS+r7gjVmH=8jvEo(LgoIRcb!59I~rY&p5yO_Hn^)Yf!W4h?V>RSE|yreG?!uN@w7k zRXE?5Nbji8!ydt1$mKKV&!<6Cf}aQ6bGH0`qmA#3xWfBxbFNpPb(Y^p-aBvfOlxt^ z#^pmPpXi`)#C_$+mco-(k*_W}3v|7`#=dMbRCytplKDOlwbLs!9$K`ao@yFo7KDT_ z@>ZLCn+Mng4w~UBpLVddCfSeFmW#p=)5rQ@7AA^w&)6b(Md3%a868`jZLqWYBx$=a z-t-dZZ}mHi0uxgB@w^^U<{^e*TH_Wed|oMP1j@P0@xF7uX~2q073^*~#uR>$ejLpA zi#JZt=D>?`pHJEn#O`4I2>0il?$WN?K||Qz{>rAb3#?-d;^K_fn;h5eRPFRNEB65e zx$z=>J$)TEe`HR^s=yNDRFx-EK4tLnaNtI#DwK*I8}l=Gd%O-d_w0;zhuevKbrEH- z#yxvZCmG2RGe-D}2_!Epqwt9;IP@yJ_zPy)$c2S*_~>CE7I6ltyZsyt4k?j`d%U2` z?Kg=>!P06)g4$t8RHz)Xh+bM!>`~BrS6T0p&ZeQ{fUoL4W;ME@gWiMvRlK#Wf zEO`(ar@H^k!v##OQ{(CMePeez;w;o)Sl&$+K{|T;Z@@F8H48diz9_y zo8jie%t^`{_G$97c#GfF75_OL=uKPs3zlanuM&;)I3Cs!w*MUYFWJGkgbpQ?tgpNG z&LA_!Ig!l9PW&Q4g9`Ptij6IU@F{?U#__ffoYP0H6h}U<16KPtlL`)j*Lc>bhG_MP zs(jyeya4LP-6KMnAi2)%0o<(@vm$5?kW9K|U^Gv?6c8*AZ2BN#z#Ol6lh_ z%?vUPn;7ev@|tcDq4Cghc%HF9PFn4d;mq-7GhPIP)m>N$w&#S#X@AsEY{$<*cOa6r z+#&64EUd`Dv4ZO+{L-rEcem>W{G#*x2>nSHJsF#lEbTB??Cq*has#n2$D6pAwd`Yv zQ=YCHM9#{f4IY)+96X@B=6h!ex|dYDPF!bWc=i5axY*^%(Mf13*r?zR*KM&SLGJo-1p;2&3a4_{PlYN* zSom^Lt!eGB&}AigEIyHW_HB}~y4F`nJM2g|$79GMm-oAU2NNhMuk*|T%Wam~RagGx zYE4o!BU1<=^xY{-#tN5nY|BP8wN~_xvndga*S{4p#m*>S(GQhof32;@-=OYbNFwd1 zpFVkS?x!c@+$n?|*Ha8sviwyyZ3{&?{@xBvtha^DQi`wMMVfN`C!<4;O_zjZQ!!o2T>ce*SdJx4{;&oI<7CF9R#Ij^EN*TVCjdPoc?o3RR| z7UgO7y*fw7UEZ(vxlNWNWW6WH{?Xp-{dFn1g+fo+rc!@xne?vdqqD>sL~sliL9SUD z`C|{q-K4Asl5>PO2kSD2a8wM9;(GusfS$j>YxYnkuJs)=NVyPwXO1^Xd{b=@Ka1Bk zBNJi_2If1S!IRwtR|{Y|f!&$ICuON7?%X|Ggb=lsjKoH46|MkCsKDnLeMVMBgK%BsozW#%yW2Hm&J&MS1D7Vv(TA(2R|YU<9)Ft>dnnJ*>XPGC z8_Mb_^q973d{aNZ^_39adizNP^jZ|&dD3xCYQc(=ffZ)Qku)x7o_7NcKJ5E`iMQKy z+P^}bjS+6!i%LA|P#dGn+{Jdr`fD|GSeLl$b=a+bK@34jcKq^}>1yo53-%>>LA{i6 zXu3`Pt!Jbw82-N6zWGVD+JrHl?|Jvkmp7u4xUnHsgw7xEH_#Y#z{fo=J10p`txuj^ z(N{Pb(Xyu{cp(AD6*(y$P0w2I%iE4UpF(gV8!4%6#ShmW#d27Jg!91{+*Iugpe7A_ zng^SU!$H~*yF<=qyreQDTh&f!bmJF1F=SvD5RqN2O|6qT%O*EKu|{9?+Y- z4a$uKHgK?=tzZ$(5Fg1EfFX+R(#AUby8wu`*Xn_(5(s8y8jOt{w>zlcAww}QwW)U?+jLlU58r$EQq&+i{BjaGmls{WXthE7{nLO2n5vub5gXFw9PXMf@)t$KlS2`TX3UQ^yM zkcA7(bg%lIQ?mF|tC;Fsrw?RVx_Qo)Hl%{P=tJf1~5LtPbJ2$wm!Ix^L~*NEF2izxhr}?0ICQ4x(Wu5_+RDbJF(VxK);d0qgi*&e zQO-x%8W|&*YD>NM=(f6DUw{gCZJEsRtqP^FKLUM!C%aTJ^torvIoF;ZY0fG2?IK0b zu0Da3%x=oz6{`G<%qY4bfhy3q6D4R*EngB1rs+#tS>D}mxx4^A?h*6E z$Ssu)!gJ>(*3*GJN@MV18=hd$5YyN4m9o^cLga&2lo5Od&p(-jpvmuk?$+?r<#skN zmd@Y8-;tFDy*~#}{(<_cfZTjFb(*0jvoif+w6HE{w+FF&*3DMUiU-lv_b8Tfstp44 zHX0QyILvHui9UY!Jo_P80ud|%_*yE$m`b2EKs~UCx z%J_BSZ81-a2NSd43-uWmh^PKNl~&?17RO;-XBa2XFnDx5ZHlFf?T!v!opPLu+WGP^ z;EQyJAXgaF^)MNKXt+ay+Bh3p)M{m&9#rE2bzlGIYl7a>Ot)+H1^VK*HiZWbUdY0t0&##;K(bMdYiP(VipP&W6AbZM|+YEz~;=;6K z^bNyoi4AYQiCLJ;IKL9Iu#nO&vA0C0Z73Mvd|s>Ch?9TWskj|_Akmppr>Q-A-*UW% zB$ytw1N1w47C9=|(uI-&67ql!h4A=A&tAAUVS*c(V2qXdnQ?b1SEIKe`jvRPf`+li z@m{L{eGhBn+4sWM5lZk!)60+B-PtI)Jn zU8|g|=Gc(x{TVAjK#=vvzoDv%FJ|d8nwgTBrF^56PCb&WUP<#X1@ax;zpdx#;?3*j zupbotr)7qzrlhK?D^JP4;BdU{W_8HQhR?fx;3-dR9}mi^B?I}-e!y=Hgvebg@s%zM zsHq4d-8cq-vP*LMbkRDvfimVD(foIw`oW;DOd@)iAf6#jbQ2ZeC-+%#AmziC7c0K5 zDkUMf7suuEIl*D_E2od0O#A{JBjZ0h4e!dOY9XBV@H@!L-_5Uk8YhIpv(_2XrjhgZ2b&88oV|6@Q$wLa<)6+i6FPOrxJ9)# z4lMbI!4_LaQT$G>PMU0-ikzJ~>RLF(=Tb=uHU-q5B52Xux*l>C$@&I7g^#j*{fID| zz4Bf~=4?E%c(g%JU2QSLE6QT}?v9>g`)hRlYHSM2W;)`*_*taV&gGCMnWGDAAPNpc zu&{l)2ubrA+phw=p1@`A66k)B0aR~{wpErXajVkn6T-H&RpfFy%fx;^oVX}(j&ofK z)t6#g-Qe&$5?Rw*p;Kx@u?LI}O9C^fI%nfuiTGEF30jZkF#w=Oq{bMxnW1mTq#22s z5|J%~?>`}p?J+WBCc?I|f~yKOmyur%k6Pj}2P4FfL|^s-eyP;`qv~bcgft&+UPwEah zha^robFhJ&M@Ea}IedI#88rk*dbQ>Tkp9!CPcOyxwTI~m;dVlt+SA@WBh=dkuNV2W zvisW#yU(|bzPdzrrQiQ`1@fixD6Q`wDIS1S2C7fM&KKN3fE&a_zv8B6G|!c1H8x>7 zzR}&4k@O*l-lSL5^SauB>mQtEh1@yBQBoJc%>HHAoV)+{0u0S3BjjS~B|q54@S79B zGz`v`t2;5%O<2lOP|FqX!EE=*L$(CZw^6!{H&ezLYjh=9VW9If&nmV!YF|4GG#CVg zM_+!X+QL8e3VwJ&Ef+bdPE`?_5hcn@d&k$+tD9E`KD4d9A3^LSk8#g$dZ`$c*VszQ zyMM~F+hD)+ewOh_7$=NhQM}3bupf}}C*qxa>gRgdU>%D{th#2bPuX35Pfixq3{8k*JXkNV4o#3vz&hBWip%7gw~{i9m60HC=^>R@-%`PI9p zj1Tr_DA7?^1xEHme4O2tWf-vO9giQ2Jj|n5n_(ACX=)gs@GsHB^rRVu@#x3&0_IR_ zpY--Y#fIp1k51K4YZLz5K+m0@zTAtK*myJ zu$0TvvoeP?q56yc0@k)4Ox`MdmgfQS$*ut7%Rb;p`Fn|B)1KF(kdaDjE7c~(@#!NU zo5pRe4Lm-uWHZkUR^hl~SWoT<`z)S8MH3XBln+hRxxEb4C2uUgUwMc^M2J#XZ4_%N zJsjYawtIV(pl>gYRr0AK8hp6PeEB^AF#wDh-i_$qJ;SiWjIub~k!NI!Z9b4;cjjdS zvFq%*_32?Lngr#{P;qLQw=mTt5tlU!^W663H3gTyv%A=RR-;}5w3e7ucca+gD6&VY zZb>pM5?OIJ61nm`j2#J`b~dhS_zuxMaQgg*9dxP*TT_uK9F^+PP{cJ$Y6s>fo}|s= zHMW6s+mWa(b9!E|A7uaShOK`WQ6+sRQGO@gU_#?yU-o1h<6avQ2PJ`l)X>8NC)wWs zu~bZTV+>2xXu3gQkurk@04rVzs>MhIXr$7-i$AH<^o~XRhM?0k(lehNZ-~t_o@el<|>xiaVyL5b>`FIA5 zd8@2i>?A~8-l|yY1|YgKNRu*XkM$E^zPA|?e-$oa{|xY#SKW4gR`7O)&wJ@Abzf68 zzq9So2AQaAyg(QChL3StYt_y?31w6tDY!DR2Lr4)kEHa0S^pp!2^Gl!2j#i{52;eN z8t`8aZ5^PKoN*Fp6d}JO=4@eUgp{gXR$hMPg26`woDljQIfD{sw2pTmFQqN?Jcj21s53 zRTQ4aAS{BaV;qbN^71rs0oSO+LXzhaKWdavimlu&gPYqBe!slZnXMk?TX$}86nG*Pt~eY@^7u0?GUE35QJ?7coP9S!!8L(ZPtFH z1_Wi4ZL~-UQ&j*Yf~KWUWwCEh2;K;OHqPSy?muAw1>b`dL6Gz=X?DX~I|I1Yi@M0N zfJ_D1;C9AIw3=Hs#P^mOieV9juZxS5jXwukV*!?=d8zo$WP=wa(y2Rs;ab&BqB4T> zK&(eL3t}K$Tm5-X$%q#7YE713x~EkI0{L~<7g-36o=+5V@lcsiz0=y8I6({kbFLY$ z@}&)LHascbyitIf4>fp&&$O(gz{^Kj{7wR|vnwPdq$#@q$jeehrd~9$C|^4r-jp7X zQm`AgBsc<)+B=E%aQGBT@mD#7q~NZZ&f?4C`>3hCBlMn+(+Lne(=P9TR9(j3i20CQ zZZ_?naKMU+?hhGr$?u=L8J{A`Y3k+rYHH3{7~SwS>}mBWT{(9gE-<*)+R5j(T!}kd zJ3q5V{1a=>wt9Pi^1!$
+ 커밍 +

이용약관 변경 안내

- 안녕하세요, Coming입니다.
+ 안녕하세요, 커밍입니다.
이용약관이 아래와 같이 변경되어 안내드립니다.

@@ -33,7 +35,7 @@

전체 원문은 아래 링크에서 확인하실 수 있습니다.
- https://coming.example.com/policy + https://comingg.com/policy

From 70c38430881443c564a80013296eb04621d93416 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 16:59:55 +0900 Subject: [PATCH 52/54] =?UTF-8?q?[fix]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=9B=90=EC=9E=90=EC=84=B1=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오프셋 페이징으로 인해 PENDING 대상이 누락되던 문제를 id 커서 기반 리더로 교체 - 최초 발송 실패 시 재시도 횟수가 먼저 소진되던 markFailed 로직 수정 - 동시 가입 시 약관 동의 저장 실패가 예외 변환 범위 밖에 있던 문제 수정 - SMTP 연결/응답 타임아웃 및 STARTTLS 필수화 설정 추가 - PolicyController 등록 API 테스트 보강 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/auth/service/AuthService.java | 4 +- .../batch/PolicyNotificationBatchConfig.java | 27 +--- ...PolicyNotificationPendingTargetReader.java | 49 +++++++ .../entity/PolicyNotificationTarget.java | 4 +- .../PolicyNotificationTargetRepository.java | 4 +- src/main/resources/application.yaml | 3 + ...cyNotificationPendingTargetReaderTest.java | 101 ++++++++++++++ .../batch/PolicyNotificationSenderTest.java | 25 +++- .../controller/PolicyControllerTest.java | 132 ++++++++++++++++++ 9 files changed, 318 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java create mode 100644 src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 59b7687..8d5a6b3 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -140,9 +140,9 @@ public TokenResponse register(Long userId, RegisterRequest request) { request.birthYear(), Boolean.TRUE.equals(request.agreedMarketing()) ); - recordPolicyAgreement(userId, PolicyType.TERMS); - recordPolicyAgreement(userId, PolicyType.PRIVACY); try { + recordPolicyAgreement(userId, PolicyType.TERMS); + recordPolicyAgreement(userId, PolicyType.PRIVACY); userRepository.flush(); } catch (DataIntegrityViolationException e) { throw new NicknameDuplicateException(); diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java index 87af58e..7077595 100644 --- a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java @@ -1,24 +1,15 @@ package com.Coming.Backend.policy.batch; -import com.Coming.Backend.policy.entity.NotificationStatus; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; -import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; -import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.core.job.Job; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.job.parameters.RunIdIncrementer; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.Step; import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.infrastructure.item.data.RepositoryItemReader; -import org.springframework.batch.infrastructure.item.data.builder.RepositoryItemReaderBuilder; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.domain.Sort; import org.springframework.transaction.PlatformTransactionManager; @Configuration @@ -30,7 +21,7 @@ public class PolicyNotificationBatchConfig { private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; - private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; private final PolicyNotificationMailProcessor policyNotificationMailProcessor; private final PolicyNotificationTargetWriter policyNotificationTargetWriter; @@ -54,24 +45,10 @@ public Step targetCreationStep() { public Step mailSendStep() { return new StepBuilder("mailSendStep", jobRepository) .chunk(CHUNK_SIZE) - .reader(mailSendItemReader(null)) + .reader(policyNotificationPendingTargetReader) .processor(policyNotificationMailProcessor) .writer(policyNotificationTargetWriter) .transactionManager(transactionManager) .build(); } - - @Bean - @StepScope - public RepositoryItemReader mailSendItemReader( - @Value("#{jobParameters['policyId']}") Long policyId) { - return new RepositoryItemReaderBuilder() - .name("mailSendItemReader") - .repository(policyNotificationTargetRepository) - .methodName("findByPolicyIdAndStatus") - .arguments(List.of(policyId, NotificationStatus.PENDING)) - .sorts(Map.of("id", Sort.Direction.ASC)) - .pageSize(CHUNK_SIZE) - .build(); - } } diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java new file mode 100644 index 0000000..f98831b --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.Iterator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 정책 알림 대상을 id 오름차순 커서 기반으로 읽는다. + * offset 페이징을 사용하면 처리된 항목이 PENDING에서 벗어나며 결과 집합이 줄어들어 + * 다음 페이지 조회 시 대상을 건너뛰는 문제가 있어, 마지막으로 읽은 id를 커서로 다음 배치를 조회한다. + */ +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationPendingTargetReader implements ItemReader { + + private static final int PAGE_SIZE = 20; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private Long lastId = 0L; + private Iterator currentBatch = List.of().iterator(); + + @Override + public PolicyNotificationTarget read() { + if (!currentBatch.hasNext()) { + List batch = policyNotificationTargetRepository + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + policyId, NotificationStatus.PENDING, lastId, PageRequest.of(0, PAGE_SIZE)); + if (batch.isEmpty()) { + return null; + } + lastId = batch.get(batch.size() - 1).getId(); + currentBatch = batch.iterator(); + } + return currentBatch.next(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java index a8aad82..7a6ba98 100644 --- a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -50,7 +50,9 @@ public void markSent() { } public void markFailed() { + if (this.status == NotificationStatus.FAILED) { + this.retryCount += 1; + } this.status = NotificationStatus.FAILED; - this.retryCount += 1; } } diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index ac915c0..ae0fdb1 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -3,7 +3,6 @@ import com.Coming.Backend.policy.entity.NotificationStatus; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import java.util.List; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,7 +10,8 @@ public interface PolicyNotificationTargetRepository extends JpaRepository findByPolicyId(Long policyId); - Page findByPolicyIdAndStatus(Long policyId, NotificationStatus status, Pageable pageable); + List findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + Long policyId, NotificationStatus status, Long id, Pageable pageable); List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 72f5938..b4078e3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -70,8 +70,11 @@ spring: mail: smtp: auth: true + connectiontimeout: 5000 + timeout: 10000 starttls: enable: true + required: true batch: job: diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java new file mode 100644 index 0000000..dd15ff9 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java @@ -0,0 +1,101 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +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.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationPendingTargetReaderTest { + + @InjectMocks + private PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long id) { + return PolicyNotificationTarget.builder() + .id(id) + .policyId(POLICY_ID) + .userId(id) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_return_items_in_order_when_first_batch_fetched_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + PolicyNotificationTarget target1 = buildTarget(1L); + PolicyNotificationTarget target2 = buildTarget(2L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of(target1, target2)); + + // when + PolicyNotificationTarget firstResult = policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget secondResult = policyNotificationPendingTargetReader.read(); + + // then + assertThat(firstResult).isSameAs(target1); + assertThat(secondResult).isSameAs(target2); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20)); + } + + @Test + void should_fetch_next_batch_using_last_read_id_as_cursor_when_current_batch_exhausted_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + List firstBatch = List.of(buildTarget(19L), buildTarget(20L)); + PolicyNotificationTarget target21 = buildTarget(21L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(firstBatch); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20))) + .willReturn(List.of(target21)); + + // when + policyNotificationPendingTargetReader.read(); + policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget resultFromNextBatch = policyNotificationPendingTargetReader.read(); + + // then + assertThat(resultFromNextBatch).isSameAs(target21); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20)); + } + + @Test + void should_return_null_when_repository_returns_empty_batch_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of()); + + // when + PolicyNotificationTarget result = policyNotificationPendingTargetReader.read(); + + // then + assertThat(result).isNull(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java index 121a919..da24bd2 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -115,7 +115,7 @@ void should_mark_sent_when_send_succeeds_given() { } @Test - void should_mark_failed_and_increment_retry_count_when_send_throws_exception_given() { + void should_mark_failed_without_incrementing_retry_count_when_initial_send_throws_exception_given() { // given PolicyNotificationTarget target = buildTarget(); User user = buildUser(1L, "iu@coming.com"); @@ -126,6 +126,29 @@ void should_mark_failed_and_increment_retry_count_when_send_throws_exception_giv // when policyNotificationSender.sendAndMark(target, user, policyDocument); + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(0); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_already_failed_send_throws_exception_again_given() { + // given + PolicyNotificationTarget target = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.FAILED) + .retryCount(0) + .build(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + // then assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); assertThat(target.getRetryCount()).isEqualTo(1); diff --git a/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java new file mode 100644 index 0000000..4fc48b8 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java @@ -0,0 +1,132 @@ +package com.Coming.Backend.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.service.PolicyService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PolicyControllerTest { + + private MockMvc mockMvc; + + @Mock + private PolicyService policyService; + + @InjectMocks + private PolicyController policyController; + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(policyController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PolicyRegisterRequest validRequest() { + return new PolicyRegisterRequest( + PolicyType.TERMS, + "1.0.0", + LocalDate.of(2026, 1, 1), + "이용약관 개정", + "https://coming.example.com/policies/terms/1.0.0" + ); + } + + @Test + void should_return_201_when_register_request_is_valid() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + PolicyResponse response = new PolicyResponse( + 1L, PolicyType.TERMS, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.type").value("TERMS")) + .andExpect(jsonPath("$.version").value("1.0.0")) + .andExpect(jsonPath("$.effectiveDate").value("2026-01-01")) + .andExpect(jsonPath("$.changeSummary").value("이용약관 개정")) + .andExpect(jsonPath("$.detailUrl").value("https://coming.example.com/policies/terms/1.0.0")); + } + + @Test + void should_return_400_when_type_is_null_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + null, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_version_exceeds_max_length_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + PolicyType.TERMS, "1".repeat(51), LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_policy_version_already_registered() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))) + .willThrow(new PolicyVersionDuplicateException()); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.POLICY_VERSION_DUPLICATE.name())) + .andExpect(jsonPath("$.message").exists()); + } +} From d5a1245fa57977d99dfd98b8e199eff1c582bc3c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 17:00:42 +0900 Subject: [PATCH 53/54] =?UTF-8?q?[refactor]=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EC=9E=AC=EB=8F=99=EC=9D=98(requiresReconsent)=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 약관에 명시된 변경사항 묵시적 동의 조항으로 충분하다는 PM 판단에 따라 재동의 필요 여부를 별도로 관리하지 않기로 결정. 등록 API 요청/응답, 엔티티, 메일 발송 로직·템플릿에서 관련 필드를 제거하고 컬럼을 삭제하는 마이그레이션을 추가. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Coming/Backend/policy/dto/PolicyRegisterRequest.java | 5 +---- .../java/com/Coming/Backend/policy/dto/PolicyResponse.java | 6 ++---- .../com/Coming/Backend/policy/entity/PolicyDocument.java | 3 --- .../Coming/Backend/policy/mail/PolicyNoticeMailSender.java | 1 - .../com/Coming/Backend/policy/service/PolicyService.java | 1 - .../V37__drop_policy_document_requires_reconsent.sql | 1 + src/main/resources/templates/mail/policy-change-notice.html | 4 ---- .../Backend/auth/service/AuthServiceRegisterTest.java | 1 - .../com/Coming/Backend/auth/service/AuthServiceTest.java | 1 - .../policy/batch/PolicyNotificationMailProcessorTest.java | 1 - .../policy/batch/PolicyNotificationRetrySchedulerTest.java | 1 - .../Backend/policy/batch/PolicyNotificationSenderTest.java | 1 - .../Backend/policy/mail/PolicyNoticeMailSenderTest.java | 2 -- .../Coming/Backend/policy/service/PolicyServiceTest.java | 5 +---- 14 files changed, 5 insertions(+), 28 deletions(-) create mode 100644 src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java index 84f05bc..2ab920d 100644 --- a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java @@ -22,9 +22,6 @@ public record PolicyRegisterRequest( @NotBlank @Size(max = 500) - String detailUrl, - - @NotNull - Boolean requiresReconsent + String detailUrl ) { } diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java index 2a232cf..722f989 100644 --- a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java @@ -10,8 +10,7 @@ public record PolicyResponse( String version, LocalDate effectiveDate, String changeSummary, - String detailUrl, - boolean requiresReconsent + String detailUrl ) { public static PolicyResponse from(PolicyDocument policyDocument) { return new PolicyResponse( @@ -20,8 +19,7 @@ public static PolicyResponse from(PolicyDocument policyDocument) { policyDocument.getVersion(), policyDocument.getEffectiveDate(), policyDocument.getChangeSummary(), - policyDocument.getDetailUrl(), - policyDocument.isRequiresReconsent() + policyDocument.getDetailUrl() ); } } diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java index f7ac6a0..2058304 100644 --- a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java @@ -43,7 +43,4 @@ public class PolicyDocument extends BaseCreatedEntity { @Column(name = "detail_url", nullable = false, length = 500) private String detailUrl; - - @Column(name = "requires_reconsent", nullable = false) - private boolean requiresReconsent; } diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java index 80108a3..cd4ae23 100644 --- a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -60,7 +60,6 @@ private String renderHtml(PolicyDocument policyDocument) { context.setVariable("effectiveDate", policyDocument.getEffectiveDate()); context.setVariable("changeSummary", policyDocument.getChangeSummary()); context.setVariable("detailUrl", policyDocument.getDetailUrl()); - context.setVariable("requiresReconsent", policyDocument.isRequiresReconsent()); context.setVariable("fromAddress", fromAddress); context.setVariable("logoContentId", LOGO_CONTENT_ID); return templateEngine.process(TEMPLATE_NAME, context); diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java index ef70256..12ce0f0 100644 --- a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -36,7 +36,6 @@ public PolicyResponse registerPolicy(PolicyRegisterRequest request) { .effectiveDate(request.effectiveDate()) .changeSummary(request.changeSummary()) .detailUrl(request.detailUrl()) - .requiresReconsent(Boolean.TRUE.equals(request.requiresReconsent())) .build(); PolicyDocument saved; try { diff --git a/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql new file mode 100644 index 0000000..06d64af --- /dev/null +++ b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql @@ -0,0 +1 @@ +ALTER TABLE policy_document DROP COLUMN requires_reconsent; diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html index 78e96ae..fef7e74 100644 --- a/src/main/resources/templates/mail/policy-change-notice.html +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -28,10 +28,6 @@

-

- 본 변경사항은 재동의가 필요한 사항입니다. 서비스 계속 이용을 위해 앱 또는 웹에서 재동의를 완료해주세요. -

-

전체 원문은 아래 링크에서 확인하실 수 있습니다.
diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java index fa5d97b..6443028 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java @@ -92,7 +92,6 @@ private PolicyDocument buildPolicyDocument(PolicyType type) { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("변경 요약") .detailUrl("https://coming.example.com/policy") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index b865b6b..9705688 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -117,7 +117,6 @@ private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { .effectiveDate(LocalDate.now()) .changeSummary("최초 시행") .detailUrl("https://coming.example.com/policy") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java index b1dc853..b165820 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java @@ -65,7 +65,6 @@ private PolicyDocument buildPolicyDocument() { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("이용약관 개정") .detailUrl("https://coming.com/policy/terms/1.0") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java index e26a8f8..ecb8879 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -69,7 +69,6 @@ private PolicyDocument buildPolicyDocument() { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("이용약관 개정") .detailUrl("https://coming.com/policy/terms/1.0") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java index da24bd2..42b5d00 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -55,7 +55,6 @@ private PolicyDocument buildPolicyDocument() { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("이용약관 개정") .detailUrl("https://coming.com/policy/terms/1.0") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java index 91668e6..7c9218a 100644 --- a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java +++ b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java @@ -45,7 +45,6 @@ private PolicyDocument buildPolicyDocument(PolicyType type) { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("개인정보 수집 항목 변경") .detailUrl("https://coming.example.com/policy/1.0.0") - .requiresReconsent(true) .build(); } @@ -88,7 +87,6 @@ void should_pass_policy_document_fields_to_template_context_when_send_called() { assertThat(capturedContext.getVariable("changeSummary")).isEqualTo(policyDocument.getChangeSummary()); assertThat(capturedContext.getVariable("effectiveDate")).isEqualTo(policyDocument.getEffectiveDate()); assertThat(capturedContext.getVariable("detailUrl")).isEqualTo(policyDocument.getDetailUrl()); - assertThat(capturedContext.getVariable("requiresReconsent")).isEqualTo(policyDocument.isRequiresReconsent()); } @Test diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java index 1374404..654c346 100644 --- a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -46,8 +46,7 @@ private PolicyRegisterRequest buildRequest() { VERSION, LocalDate.of(2026, 1, 1), "이용약관 최초 등록", - "https://coming.example.com/policy/terms/1.0.0", - true + "https://coming.example.com/policy/terms/1.0.0" ); } @@ -59,7 +58,6 @@ private PolicyDocument buildPolicyDocument(Long id, PolicyRegisterRequest reques .effectiveDate(request.effectiveDate()) .changeSummary(request.changeSummary()) .detailUrl(request.detailUrl()) - .requiresReconsent(request.requiresReconsent()) .build(); } @@ -82,7 +80,6 @@ void should_return_policy_response_when_valid_request_given() { assertThat(result.effectiveDate()).isEqualTo(request.effectiveDate()); assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); - assertThat(result.requiresReconsent()).isTrue(); verify(eventPublisher).publishEvent(new PolicyRegisteredEvent(1L)); } From 0831dd95431957b9e13d127f00e7f3e3fe24cc3a Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 17:00:53 +0900 Subject: [PATCH 54/54] =?UTF-8?q?[style]=20=EC=A0=95=EC=B1=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A9=94=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=97=AC=EB=B0=B1=20=EB=B0=8F=20=EB=B0=9C?= =?UTF-8?q?=EC=8B=A0=EC=9E=90=20=ED=91=9C=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원문 링크 안내 문구와 링크 사이에 빈 줄 추가 - 발신자 표시명을 "커밍"으로 통일 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- src/main/resources/templates/mail/policy-change-notice.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html index fef7e74..4625db7 100644 --- a/src/main/resources/templates/mail/policy-change-notice.html +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -29,7 +29,7 @@

- 전체 원문은 아래 링크에서 확인하실 수 있습니다.
+ 전체 원문은 아래 링크에서 확인하실 수 있습니다.

https://comingg.com/policy @@ -39,7 +39,7 @@

본 메일은 관련 법령에 따른 정책 변경 사전 고지 목적으로 발송되는 필수 안내이며, 수신거부가 적용되지 않습니다.
- 발신: Coming (coming@example.com) + 발신: 커밍 (coming@example.com)