From 03fe4738af50b7f0c31167d8460797283cc1aea3 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Thu, 10 Sep 2026 16:01:02 +0900 Subject: [PATCH 01/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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/30] =?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); } }