diff --git a/src/main/java/com/devpick/domain/community/dto/PostSummaryResponse.java b/src/main/java/com/devpick/domain/community/dto/PostSummaryResponse.java index fb6c360..f7ba508 100644 --- a/src/main/java/com/devpick/domain/community/dto/PostSummaryResponse.java +++ b/src/main/java/com/devpick/domain/community/dto/PostSummaryResponse.java @@ -16,9 +16,20 @@ public record PostSummaryResponse( String authorNickname, Job authorJob, String authorProfileImage, - Instant createdAt + Instant createdAt, + long answerCount, + String contentPreview, + String topAnswerPreview ) { - public static PostSummaryResponse of(Post post) { + private static final int CONTENT_PREVIEW_LENGTH = 150; + private static final int ANSWER_PREVIEW_LENGTH = 100; + + public static PostSummaryResponse of(Post post, long answerCount, String topAnswerPreview) { + String content = post.getContent(); + String contentPreview = content != null && content.length() > CONTENT_PREVIEW_LENGTH + ? content.substring(0, CONTENT_PREVIEW_LENGTH) + "..." + : content; + return new PostSummaryResponse( post.getId(), post.getTitle(), @@ -27,7 +38,17 @@ public static PostSummaryResponse of(Post post) { post.getUser().getNickname(), post.getUser().getJob(), post.getUser().getProfileImage(), - post.getCreatedAt() != null ? post.getCreatedAt().toInstant(ZoneOffset.UTC) : null + post.getCreatedAt() != null ? post.getCreatedAt().toInstant(ZoneOffset.UTC) : null, + answerCount, + contentPreview, + topAnswerPreview ); } + + public static String truncateAnswerPreview(String content) { + if (content == null) return null; + return content.length() > ANSWER_PREVIEW_LENGTH + ? content.substring(0, ANSWER_PREVIEW_LENGTH) + "..." + : content; + } } diff --git a/src/main/java/com/devpick/domain/community/repository/AiAnswerRepository.java b/src/main/java/com/devpick/domain/community/repository/AiAnswerRepository.java index 5afed0d..3b93591 100644 --- a/src/main/java/com/devpick/domain/community/repository/AiAnswerRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/AiAnswerRepository.java @@ -2,6 +2,9 @@ import com.devpick.domain.community.entity.AiAnswer; 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; import java.util.Optional; import java.util.UUID; @@ -9,4 +12,8 @@ public interface AiAnswerRepository extends JpaRepository { Optional findByPost_Id(UUID postId); + + @Modifying + @Query("DELETE FROM AiAnswer a WHERE a.post.id = :postId") + void deleteByPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/domain/community/repository/AiQuestionRepository.java b/src/main/java/com/devpick/domain/community/repository/AiQuestionRepository.java index 16d3218..621f1a3 100644 --- a/src/main/java/com/devpick/domain/community/repository/AiQuestionRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/AiQuestionRepository.java @@ -2,6 +2,9 @@ import com.devpick.domain.community.entity.AiQuestion; 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; import java.util.Optional; import java.util.UUID; @@ -9,4 +12,8 @@ public interface AiQuestionRepository extends JpaRepository { Optional findByPost_Id(UUID postId); + + @Modifying + @Query("DELETE FROM AiQuestion q WHERE q.post.id = :postId") + void deleteByPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/domain/community/repository/AnswerLikeRepository.java b/src/main/java/com/devpick/domain/community/repository/AnswerLikeRepository.java index 9940e00..4b19d18 100644 --- a/src/main/java/com/devpick/domain/community/repository/AnswerLikeRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/AnswerLikeRepository.java @@ -2,6 +2,9 @@ import com.devpick.domain.community.entity.AnswerLike; 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; import java.util.Optional; import java.util.UUID; @@ -13,4 +16,12 @@ public interface AnswerLikeRepository extends JpaRepository { Optional findByAnswer_IdAndUser_Id(UUID answerId, UUID userId); long countByAnswer_Id(UUID answerId); + + @Modifying + @Query("DELETE FROM AnswerLike al WHERE al.answer.id = :answerId") + void deleteByAnswerId(@Param("answerId") UUID answerId); + + @Modifying + @Query("DELETE FROM AnswerLike al WHERE al.answer.id IN (SELECT a.id FROM Answer a WHERE a.post.id = :postId)") + void deleteByPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/domain/community/repository/AnswerRepository.java b/src/main/java/com/devpick/domain/community/repository/AnswerRepository.java index c966ce6..8b84803 100644 --- a/src/main/java/com/devpick/domain/community/repository/AnswerRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/AnswerRepository.java @@ -4,6 +4,7 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -25,4 +26,11 @@ public interface AnswerRepository extends JpaRepository { @Query("SELECT a FROM Answer a JOIN FETCH a.post WHERE a.user.id = :userId ORDER BY a.createdAt DESC") List findByUserIdWithPost(@Param("userId") UUID userId); + + @Query("SELECT a FROM Answer a JOIN FETCH a.post WHERE a.post.id IN :postIds ORDER BY a.post.id, a.createdAt ASC") + List findByPostIdsOrderByCreatedAtAsc(@Param("postIds") List postIds); + + @Modifying + @Query("DELETE FROM Answer a WHERE a.post.id = :postId") + void deleteByPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/domain/community/repository/CommentRepository.java b/src/main/java/com/devpick/domain/community/repository/CommentRepository.java index 5d57aec..4a96266 100644 --- a/src/main/java/com/devpick/domain/community/repository/CommentRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/CommentRepository.java @@ -2,6 +2,9 @@ import com.devpick.domain.community.entity.Comment; 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; import java.util.List; import java.util.UUID; @@ -9,4 +12,12 @@ public interface CommentRepository extends JpaRepository { List findByAnswer_IdOrderByCreatedAtAsc(UUID answerId); + + @Modifying + @Query("DELETE FROM Comment c WHERE c.answer.id = :answerId") + void deleteByAnswerId(@Param("answerId") UUID answerId); + + @Modifying + @Query("DELETE FROM Comment c WHERE c.answer.id IN (SELECT a.id FROM Answer a WHERE a.post.id = :postId)") + void deleteByPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/domain/community/repository/PostLikeRepository.java b/src/main/java/com/devpick/domain/community/repository/PostLikeRepository.java index 7b2a2cd..3978b4a 100644 --- a/src/main/java/com/devpick/domain/community/repository/PostLikeRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/PostLikeRepository.java @@ -2,6 +2,9 @@ import com.devpick.domain.community.entity.PostLike; 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; import java.util.Optional; import java.util.UUID; @@ -13,4 +16,8 @@ public interface PostLikeRepository extends JpaRepository { Optional findByPost_IdAndUser_Id(UUID postId, UUID userId); long countByPost_Id(UUID postId); + + @Modifying + @Query("DELETE FROM PostLike pl WHERE pl.post.id = :postId") + void deleteByPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/domain/community/repository/PostRepository.java b/src/main/java/com/devpick/domain/community/repository/PostRepository.java index b9f7c95..cb51cef 100644 --- a/src/main/java/com/devpick/domain/community/repository/PostRepository.java +++ b/src/main/java/com/devpick/domain/community/repository/PostRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -22,4 +23,6 @@ OR LOWER(p.content) LIKE LOWER(CONCAT('%', :q, '%')) Page searchByTitleOrContentContaining(@Param("q") String q, Pageable pageable); List findByUser_IdOrderByCreatedAtDesc(UUID userId); + + boolean existsByUser_IdAndTitleAndCreatedAtAfter(UUID userId, String title, LocalDateTime after); } diff --git a/src/main/java/com/devpick/domain/community/service/AnswerService.java b/src/main/java/com/devpick/domain/community/service/AnswerService.java index 3e806d2..ec8ab97 100644 --- a/src/main/java/com/devpick/domain/community/service/AnswerService.java +++ b/src/main/java/com/devpick/domain/community/service/AnswerService.java @@ -8,6 +8,7 @@ import com.devpick.domain.community.entity.Answer; import com.devpick.domain.community.entity.Comment; import com.devpick.domain.community.entity.Post; +import com.devpick.domain.community.repository.AnswerLikeRepository; import com.devpick.domain.community.repository.AnswerRepository; import com.devpick.domain.community.repository.CommentRepository; import com.devpick.domain.community.repository.PostRepository; @@ -36,6 +37,7 @@ public class AnswerService { private final HistoryRepository historyRepository; private final PointService pointService; private final CommentRepository commentRepository; + private final AnswerLikeRepository answerLikeRepository; @Transactional(readOnly = true) public AnswerListResponse getAnswers(UUID postId) { @@ -105,6 +107,10 @@ public void deleteAnswer(UUID userId, UUID postId, UUID answerId) { throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_ANSWER_ACTION); } + // 자식 레코드 순서대로 삭제 (FK 제약조건 준수) + commentRepository.deleteByAnswerId(answerId); + answerLikeRepository.deleteByAnswerId(answerId); + historyRepository.deleteByAnswerId(answerId); answerRepository.delete(answer); } diff --git a/src/main/java/com/devpick/domain/community/service/PostService.java b/src/main/java/com/devpick/domain/community/service/PostService.java index ec414c8..bd25dc2 100644 --- a/src/main/java/com/devpick/domain/community/service/PostService.java +++ b/src/main/java/com/devpick/domain/community/service/PostService.java @@ -6,7 +6,12 @@ import com.devpick.domain.community.dto.PostSummaryResponse; import com.devpick.domain.community.dto.PostUpdateRequest; import com.devpick.domain.community.entity.Post; +import com.devpick.domain.community.repository.AiAnswerRepository; +import com.devpick.domain.community.repository.AiQuestionRepository; +import com.devpick.domain.community.repository.AnswerLikeRepository; import com.devpick.domain.community.repository.AnswerRepository; +import com.devpick.domain.community.repository.CommentRepository; +import com.devpick.domain.community.repository.PostLikeRepository; import com.devpick.domain.community.repository.PostRepository; import com.devpick.domain.point.entity.PointAction; import com.devpick.domain.point.service.PointService; @@ -25,8 +30,11 @@ import org.springframework.util.StringUtils; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -37,12 +45,22 @@ public class PostService { private final UserRepository userRepository; private final HistoryRepository historyRepository; private final PointService pointService; + private final PostLikeRepository postLikeRepository; + private final AiAnswerRepository aiAnswerRepository; + private final AiQuestionRepository aiQuestionRepository; + private final CommentRepository commentRepository; + private final AnswerLikeRepository answerLikeRepository; @Transactional public PostDetailResponse createPost(UUID userId, PostCreateRequest request) { User user = userRepository.findByIdAndIsActiveTrue(userId) .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); + // 10초 이내 동일 제목 중복 제출 방지 + if (postRepository.existsByUser_IdAndTitleAndCreatedAtAfter(userId, request.title(), LocalDateTime.now().minusSeconds(10))) { + throw new DevpickException(ErrorCode.COMMUNITY_DUPLICATE_POST); + } + Post post = Post.builder() .user(user) .title(request.title()) @@ -76,9 +94,39 @@ public PostListResponse getPosts(Pageable pageable, String query) { } else { page = postRepository.findAllByOrderByCreatedAtDesc(pageable); } - List posts = page.getContent().stream() - .map(PostSummaryResponse::of) + + List postList = page.getContent(); + if (postList.isEmpty()) { + return new PostListResponse(List.of(), page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } + + List postIds = postList.stream().map(Post::getId).toList(); + + // 답변 수 배치 조회 + Map answerCountMap = answerRepository.countByPostIds(postIds).stream() + .collect(Collectors.toMap( + row -> (UUID) row[0], + row -> (Long) row[1] + )); + + // 첫 번째 답변 미리보기 배치 조회 (postId → truncated content) + Map topAnswerPreviews = answerRepository.findByPostIdsOrderByCreatedAtAsc(postIds) + .stream() + .collect(Collectors.toMap( + a -> a.getPost().getId(), + a -> PostSummaryResponse.truncateAnswerPreview(a.getContent()), + (first, second) -> first // 가장 오래된 답변 유지 + )); + + List posts = postList.stream() + .map(post -> PostSummaryResponse.of( + post, + answerCountMap.getOrDefault(post.getId(), 0L), + topAnswerPreviews.get(post.getId()) + )) .toList(); + return new PostListResponse(posts, page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages()); } @@ -114,6 +162,15 @@ public void deletePost(UUID userId, UUID postId) { throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_POST_ACTION); } + // 자식 레코드 순서대로 삭제 (FK 제약조건 준수) + commentRepository.deleteByPostId(postId); + answerLikeRepository.deleteByPostId(postId); + historyRepository.deleteByAnswerPostId(postId); + answerRepository.deleteByPostId(postId); + postLikeRepository.deleteByPostId(postId); + aiAnswerRepository.deleteByPostId(postId); + aiQuestionRepository.deleteByPostId(postId); + historyRepository.deleteByPostId(postId); postRepository.delete(post); } } diff --git a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java index d03af6c..2c5c2e2 100644 --- a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java +++ b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java @@ -4,6 +4,7 @@ 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; @@ -99,4 +100,16 @@ Page findHistoryIdsByDateRangeExcludingContentLiked( "WHERE h.id IN :ids " + "ORDER BY h.createdAt DESC") List findHistoriesWithAssociationsByIds(@Param("ids") List ids); + + @Modifying + @Query("DELETE FROM History h WHERE h.post.id = :postId") + void deleteByPostId(@Param("postId") UUID postId); + + @Modifying + @Query("DELETE FROM History h WHERE h.answer.id = :answerId") + void deleteByAnswerId(@Param("answerId") UUID answerId); + + @Modifying + @Query("DELETE FROM History h WHERE h.answer.id IN (SELECT a.id FROM Answer a WHERE a.post.id = :postId)") + void deleteByAnswerPostId(@Param("postId") UUID postId); } diff --git a/src/main/java/com/devpick/global/common/exception/ErrorCode.java b/src/main/java/com/devpick/global/common/exception/ErrorCode.java index 658494f..9e0bbe5 100644 --- a/src/main/java/com/devpick/global/common/exception/ErrorCode.java +++ b/src/main/java/com/devpick/global/common/exception/ErrorCode.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; + @Getter @RequiredArgsConstructor public enum ErrorCode { @@ -81,6 +82,7 @@ public enum ErrorCode { COMMUNITY_POST_NOT_LIKED(HttpStatus.NOT_FOUND, "COMMUNITY_010", "좋아요하지 않은 게시글입니다."), COMMUNITY_ANSWER_ALREADY_LIKED(HttpStatus.CONFLICT, "COMMUNITY_011", "이미 좋아요한 답변입니다."), COMMUNITY_ANSWER_NOT_LIKED(HttpStatus.NOT_FOUND, "COMMUNITY_012", "좋아요하지 않은 답변입니다."), + COMMUNITY_DUPLICATE_POST(HttpStatus.CONFLICT, "COMMUNITY_013", "잠시 후 다시 시도해 주세요."), // Report REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "REPORT_001", "주간 리포트를 찾을 수 없습니다."), diff --git a/src/main/java/com/devpick/global/entity/BaseTimeEntity.java b/src/main/java/com/devpick/global/entity/BaseTimeEntity.java index f37fdf8..b3fc132 100644 --- a/src/main/java/com/devpick/global/entity/BaseTimeEntity.java +++ b/src/main/java/com/devpick/global/entity/BaseTimeEntity.java @@ -33,8 +33,9 @@ public abstract class BaseTimeEntity { @PrePersist protected void onCreate() { - this.createdAt = LocalDateTime.now(); - this.updatedAt = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; } @PreUpdate diff --git a/src/test/java/com/devpick/domain/community/controller/PostControllerTest.java b/src/test/java/com/devpick/domain/community/controller/PostControllerTest.java index c5c868f..f95ee0a 100644 --- a/src/test/java/com/devpick/domain/community/controller/PostControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/PostControllerTest.java @@ -124,7 +124,7 @@ void createPost_validationFails_returns400() throws Exception { @DisplayName("GET /posts - 목록 조회 성공 시 200과 목록 반환") void getPosts_success_returns200() throws Exception { PostSummaryResponse summary = new PostSummaryResponse( - postId, "Test Post", Level.JUNIOR, userId, "tester", Job.BACKEND, null, Instant.now()); + postId, "Test Post", Level.JUNIOR, userId, "tester", Job.BACKEND, null, Instant.now(), 2L, "content preview", null); PostListResponse listResponse = new PostListResponse(List.of(summary), 0, 20, 1L, 1); given(postService.getPosts(any(), any())).willReturn(listResponse); @@ -140,7 +140,7 @@ void getPosts_success_returns200() throws Exception { @DisplayName("GET /posts - 목록 응답에 authorJob 필드 포함") void getPosts_responseIncludesAuthorJob() throws Exception { PostSummaryResponse summary = new PostSummaryResponse( - postId, "Test Post", Level.JUNIOR, userId, "tester", Job.FRONTEND, null, Instant.now()); + postId, "Test Post", Level.JUNIOR, userId, "tester", Job.FRONTEND, null, Instant.now(), 0L, "content preview", null); PostListResponse listResponse = new PostListResponse(List.of(summary), 0, 20, 1L, 1); given(postService.getPosts(any(), any())).willReturn(listResponse); @@ -153,7 +153,7 @@ void getPosts_responseIncludesAuthorJob() throws Exception { @DisplayName("GET /posts - authorJob이 null이어도 정상 반환") void getPosts_nullAuthorJob_returns200() throws Exception { PostSummaryResponse summary = new PostSummaryResponse( - postId, "Test Post", Level.JUNIOR, userId, "tester", null, null, Instant.now()); + postId, "Test Post", Level.JUNIOR, userId, "tester", null, null, Instant.now(), 0L, "content preview", null); PostListResponse listResponse = new PostListResponse(List.of(summary), 0, 20, 1L, 1); given(postService.getPosts(any(), any())).willReturn(listResponse); diff --git a/src/test/java/com/devpick/domain/community/dto/PostSummaryResponseTest.java b/src/test/java/com/devpick/domain/community/dto/PostSummaryResponseTest.java new file mode 100644 index 0000000..707c5c4 --- /dev/null +++ b/src/test/java/com/devpick/domain/community/dto/PostSummaryResponseTest.java @@ -0,0 +1,144 @@ +package com.devpick.domain.community.dto; + +import com.devpick.domain.community.entity.Post; +import com.devpick.domain.user.entity.Job; +import com.devpick.domain.user.entity.Level; +import com.devpick.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostSummaryResponseTest { + + private User user; + + @BeforeEach + void setUp() { + user = User.builder() + .email("test@devpick.kr") + .nickname("tester") + .job(Job.BACKEND) + .level(Level.JUNIOR) + .build(); + } + + // ── truncateAnswerPreview ────────────────────────────────────────────── + + @Test + @DisplayName("truncateAnswerPreview — null 입력 시 null 반환") + void truncateAnswerPreview_null_returnsNull() { + assertThat(PostSummaryResponse.truncateAnswerPreview(null)).isNull(); + } + + @Test + @DisplayName("truncateAnswerPreview — 100자 이하 입력 시 원문 그대로 반환") + void truncateAnswerPreview_shortContent_returnsUnchanged() { + String content = "Short answer"; + assertThat(PostSummaryResponse.truncateAnswerPreview(content)).isEqualTo(content); + } + + @Test + @DisplayName("truncateAnswerPreview — 정확히 100자 입력 시 원문 그대로 반환") + void truncateAnswerPreview_exactlyHundredChars_returnsUnchanged() { + String content = "a".repeat(100); + assertThat(PostSummaryResponse.truncateAnswerPreview(content)).isEqualTo(content); + } + + @Test + @DisplayName("truncateAnswerPreview — 100자 초과 시 100자 + '...' 반환") + void truncateAnswerPreview_longContent_returnsTruncated() { + String content = "a".repeat(150); + String result = PostSummaryResponse.truncateAnswerPreview(content); + assertThat(result).endsWith("..."); + assertThat(result).isEqualTo("a".repeat(100) + "..."); + } + + // ── of() ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("of — 본문 150자 이하 시 contentPreview 그대로") + void of_shortContent_preservesContentPreview() { + Post post = buildPost("Short content"); + + PostSummaryResponse response = PostSummaryResponse.of(post, 0L, null); + + assertThat(response.contentPreview()).isEqualTo("Short content"); + } + + @Test + @DisplayName("of — 본문 150자 초과 시 150자 + '...' 로 잘림") + void of_longContent_truncatesContentPreview() { + Post post = buildPost("a".repeat(200)); + + PostSummaryResponse response = PostSummaryResponse.of(post, 5L, "top answer"); + + assertThat(response.contentPreview()).isEqualTo("a".repeat(150) + "..."); + assertThat(response.answerCount()).isEqualTo(5L); + assertThat(response.topAnswerPreview()).isEqualTo("top answer"); + } + + @Test + @DisplayName("of — 본문 null 시 contentPreview null") + void of_nullContent_nullContentPreview() { + Post post = buildPost(null); + + PostSummaryResponse response = PostSummaryResponse.of(post, 0L, null); + + assertThat(response.contentPreview()).isNull(); + } + + @Test + @DisplayName("of — createdAt null 시 Instant null") + void of_nullCreatedAt_nullInstant() { + Post post = buildPost("content"); + // @PrePersist 미실행 → createdAt = null + + PostSummaryResponse response = PostSummaryResponse.of(post, 0L, null); + + assertThat(response.createdAt()).isNull(); + } + + @Test + @DisplayName("of — createdAt 있으면 Instant로 변환") + void of_nonNullCreatedAt_returnsInstant() { + Post post = buildPost("content"); + ReflectionTestUtils.setField(post, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + + PostSummaryResponse response = PostSummaryResponse.of(post, 2L, null); + + assertThat(response.createdAt()).isNotNull(); + } + + @Test + @DisplayName("of — 모든 필드가 올바르게 매핑됨") + void of_allFieldsMappedCorrectly() { + Post post = buildPost("content"); + UUID postId = UUID.randomUUID(); + ReflectionTestUtils.setField(post, "id", postId); + + PostSummaryResponse response = PostSummaryResponse.of(post, 3L, "answer preview"); + + assertThat(response.id()).isEqualTo(postId); + assertThat(response.title()).isEqualTo("Test Post"); + assertThat(response.level()).isEqualTo(Level.JUNIOR); + assertThat(response.authorNickname()).isEqualTo("tester"); + assertThat(response.authorJob()).isEqualTo(Job.BACKEND); + assertThat(response.answerCount()).isEqualTo(3L); + assertThat(response.topAnswerPreview()).isEqualTo("answer preview"); + } + + private Post buildPost(String content) { + return Post.builder() + .user(user) + .title("Test Post") + .content(content) + .level(Level.JUNIOR) + .build(); + } +} diff --git a/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java b/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java index 9df21a8..b089ff7 100644 --- a/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java @@ -7,6 +7,7 @@ import com.devpick.domain.community.entity.Answer; import com.devpick.domain.community.entity.Comment; import com.devpick.domain.community.entity.Post; +import com.devpick.domain.community.repository.AnswerLikeRepository; import com.devpick.domain.community.repository.AnswerRepository; import com.devpick.domain.community.repository.CommentRepository; import com.devpick.domain.community.repository.PostRepository; @@ -59,6 +60,8 @@ class AnswerServiceTest { private com.devpick.domain.point.service.PointService pointService; @Mock private CommentRepository commentRepository; + @Mock + private AnswerLikeRepository answerLikeRepository; private UUID userId; private UUID postId; diff --git a/src/test/java/com/devpick/domain/community/service/PostServiceTest.java b/src/test/java/com/devpick/domain/community/service/PostServiceTest.java index 93d7d70..71d88df 100644 --- a/src/test/java/com/devpick/domain/community/service/PostServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/PostServiceTest.java @@ -4,8 +4,14 @@ import com.devpick.domain.community.dto.PostDetailResponse; import com.devpick.domain.community.dto.PostListResponse; import com.devpick.domain.community.dto.PostUpdateRequest; +import com.devpick.domain.community.entity.Answer; import com.devpick.domain.community.entity.Post; +import com.devpick.domain.community.repository.AiAnswerRepository; +import com.devpick.domain.community.repository.AiQuestionRepository; +import com.devpick.domain.community.repository.AnswerLikeRepository; import com.devpick.domain.community.repository.AnswerRepository; +import com.devpick.domain.community.repository.CommentRepository; +import com.devpick.domain.community.repository.PostLikeRepository; import com.devpick.domain.community.repository.PostRepository; import com.devpick.domain.report.entity.History; import com.devpick.domain.report.repository.HistoryRepository; @@ -26,6 +32,8 @@ import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -54,6 +62,16 @@ class PostServiceTest { private HistoryRepository historyRepository; @Mock private com.devpick.domain.point.service.PointService pointService; + @Mock + private PostLikeRepository postLikeRepository; + @Mock + private AiAnswerRepository aiAnswerRepository; + @Mock + private AiQuestionRepository aiQuestionRepository; + @Mock + private CommentRepository commentRepository; + @Mock + private AnswerLikeRepository answerLikeRepository; private UUID userId; private UUID postId; @@ -230,4 +248,75 @@ void deletePost_unauthorized_throwsException() { .isEqualTo(ErrorCode.COMMUNITY_UNAUTHORIZED_POST_ACTION)); verify(postRepository, never()).delete(any()); } + + @Test + @DisplayName("deletePost — 성공 시 자식 레코드 순서대로 모두 삭제") + void deletePost_success_deletesAllChildRecords() { + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + + postService.deletePost(userId, postId); + + verify(commentRepository).deleteByPostId(postId); + verify(answerLikeRepository).deleteByPostId(postId); + verify(historyRepository).deleteByAnswerPostId(postId); + verify(answerRepository).deleteByPostId(postId); + verify(postLikeRepository).deleteByPostId(postId); + verify(aiAnswerRepository).deleteByPostId(postId); + verify(aiQuestionRepository).deleteByPostId(postId); + verify(historyRepository).deleteByPostId(postId); + verify(postRepository).delete(post); + } + + @Test + @DisplayName("createPost — 10초 이내 동일 제목 중복 제출 시 COMMUNITY_DUPLICATE_POST 예외") + void createPost_duplicatePost_throwsException() { + PostCreateRequest request = new PostCreateRequest("Test Post", "Test Content", Level.JUNIOR); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + given(postRepository.existsByUser_IdAndTitleAndCreatedAtAfter(eq(userId), eq("Test Post"), any(LocalDateTime.class))) + .willReturn(true); + + assertThatThrownBy(() -> postService.createPost(userId, request)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.COMMUNITY_DUPLICATE_POST)); + verify(postRepository, never()).save(any()); + } + + @Test + @DisplayName("getPosts — 게시글 없으면 빈 목록 반환 (배치 조회 미호출)") + void getPosts_emptyPage_returnsEmptyList() { + given(postRepository.findAllByOrderByCreatedAtDesc(any())) + .willReturn(new PageImpl<>(List.of())); + + PostListResponse response = postService.getPosts(PageRequest.of(0, 20), null); + + assertThat(response.posts()).isEmpty(); + assertThat(response.totalElements()).isEqualTo(0L); + verify(answerRepository, never()).countByPostIds(any()); + verify(answerRepository, never()).findByPostIdsOrderByCreatedAtAsc(any()); + } + + @Test + @DisplayName("getPosts — 답변 수와 첫 번째 답변 미리보기가 포함된 목록 반환") + void getPosts_withAnswerCountsAndPreviews() { + Answer answer = Answer.builder() + .post(post) + .user(user) + .content("a".repeat(150)) + .build(); + + given(postRepository.findAllByOrderByCreatedAtDesc(any())) + .willReturn(new PageImpl<>(List.of(post))); + Object[] countRow = {postId, 3L}; + given(answerRepository.countByPostIds(any())) + .willReturn(Collections.singletonList(countRow)); + given(answerRepository.findByPostIdsOrderByCreatedAtAsc(any())) + .willReturn(List.of(answer)); + + PostListResponse response = postService.getPosts(PageRequest.of(0, 20), null); + + assertThat(response.posts()).hasSize(1); + assertThat(response.posts().get(0).answerCount()).isEqualTo(3L); + assertThat(response.posts().get(0).topAnswerPreview()).isEqualTo("a".repeat(100) + "..."); + } }