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);
}
}