diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 956a7e92..a16f8a5e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,10 @@ name: CI
on:
pull_request:
- branches: [main]
+ branches: [main, develop]
+
+permissions:
+ contents: read
jobs:
test:
diff --git a/README.md b/README.md
index ff95b646..f2d12994 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으로 알림됩니다.
diff --git a/build.gradle b/build.gradle
index 78e00387..e629fbd9 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/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java
index bf2a072e..e536576b 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,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/artists/**",
"/api/concerts/**",
"/api/releases/**",
- "/api/calendar"
+ "/api/calendar",
+ "/api/mentions/search",
+ "/api/posts/**",
+ "/api/search"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/auth/register").hasRole("PENDING")
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 dfd5f653..ae70ceb2 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,18 @@ 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, "추천하지 않은 게시글입니다."),
+ 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/concert/repository/ConcertRepository.java b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java
index b856be48..af24102d 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/CommentController.java b/src/main/java/com/Coming/Backend/post/controller/CommentController.java
new file mode 100644
index 00000000..9af008e5
--- /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/EntityPostController.java b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java
new file mode 100644
index 00000000..e073db35
--- /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/controller/MentionController.java b/src/main/java/com/Coming/Backend/post/controller/MentionController.java
new file mode 100644
index 00000000..68ee3b6a
--- /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/controller/PostCommentController.java b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java
new file mode 100644
index 00000000..adfd403a
--- /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/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 00000000..782fb395
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java
@@ -0,0 +1,129 @@
+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.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;
+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;
+
+import java.util.List;
+
+@Tag(name = "Post")
+@Validated
+@RestController
+@RequestMapping("/api/posts")
+@RequiredArgsConstructor
+public class PostController {
+
+ private final PostService postService;
+
+ @Operation(summary = "게시글 작성")
+ @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 = "이번 주 인기 게시글 조회")
+ @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")
+ @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();
+ }
+
+ @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/controller/SearchController.java b/src/main/java/com/Coming/Backend/post/controller/SearchController.java
new file mode 100644
index 00000000..a58e9cf4
--- /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/dto/CommentCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java
new file mode 100644
index 00000000..dacbf3a6
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java
@@ -0,0 +1,13 @@
+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/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 00000000..d993711c
--- /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 00000000..04a9e842
--- /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 00000000..6ba29a39
--- /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/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java
new file mode 100644
index 00000000..11e5a0b4
--- /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/dto/EntityTagRequest.java b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java
new file mode 100644
index 00000000..39dee739
--- /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 00000000..9581d774
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java
@@ -0,0 +1,25 @@
+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,
+
+ @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다")
+ List<@NotNull @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 00000000..8f94de8a
--- /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 00000000..50e40687
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java
@@ -0,0 +1,23 @@
+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,
+ Long commentCount,
+ 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 00000000..a9bd57c2
--- /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 00000000..fe3370c2
--- /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 00000000..8f197092
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java
@@ -0,0 +1,23 @@
+package com.Coming.Backend.post.dto;
+
+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;
+
+public record PostUpdateRequest(
+ PostCategory category,
+
+ @Pattern(regexp = "(?s).*\\S.*", message = "제목은 공백일 수 없습니다")
+ @Size(max = 255)
+ String title,
+
+ Object content,
+
+ @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다")
+ List<@NotNull @Valid EntityTagRequest> entityTags
+) {
+}
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 00000000..74380d35
--- /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/dto/TrendingTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java
new file mode 100644
index 00000000..fa8b7648
--- /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/entity/Comment.java b/src/main/java/com/Coming/Backend/post/entity/Comment.java
new file mode 100644
index 00000000..dde5037e
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/Comment.java
@@ -0,0 +1,73 @@
+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;
+
+ private static final String DELETED_CONTENT_PLACEHOLDER = "삭제된 댓글입니다";
+
+ public boolean isAuthoredBy(Long userId) {
+ return userId != null && this.userId.equals(userId);
+ }
+
+ public boolean isReply() {
+ return parentCommentId != null;
+ }
+
+ public void softDelete() {
+ this.deleted = true;
+ }
+
+ /**
+ * 소프트 삭제된 댓글은 본문 대신 플레이스홀더를 노출한다.
+ */
+ public String getDisplayContent() {
+ return deleted ? DELETED_CONTENT_PLACEHOLDER : content;
+ }
+
+ /**
+ * 소프트 삭제된 댓글은 작성자 정보를 노출하지 않는다(isAuthoredBy와 달리 삭제 여부까지 반영).
+ */
+ public boolean isVisibleAuthor(Long userId) {
+ return !deleted && isAuthoredBy(userId);
+ }
+}
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 00000000..acdd70e1
--- /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/EntityType.java b/src/main/java/com/Coming/Backend/post/entity/EntityType.java
new file mode 100644
index 00000000..e52d9467
--- /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 00000000..9c420235
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/Post.java
@@ -0,0 +1,68 @@
+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;
+
+ @Column(name = "comment_count", nullable = false)
+ private Long commentCount;
+
+ 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/entity/PostCategory.java b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java
new file mode 100644
index 00000000..704c4955
--- /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 00000000..f53d02d5
--- /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 00000000..7751b94f
--- /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/AlreadyLikedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java
new file mode 100644
index 00000000..21f680d9
--- /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/AlreadyRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java
new file mode 100644
index 00000000..c453a63a
--- /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/CommentForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java
new file mode 100644
index 00000000..d32b1ec7
--- /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 00000000..2b7de004
--- /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 00000000..958b7031
--- /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 00000000..8c0c6e1d
--- /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/exception/NotRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java
new file mode 100644
index 00000000..0caf1b7a
--- /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/PostContentTooLongException.java b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java
new file mode 100644
index 00000000..717ce5d6
--- /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/exception/PostForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java
new file mode 100644
index 00000000..2a9adb75
--- /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/exception/PostNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java
new file mode 100644
index 00000000..1d111812
--- /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/CommentLikeRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java
new file mode 100644
index 00000000..b4767731
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java
@@ -0,0 +1,26 @@
+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.Modifying;
+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);
+
+ 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/CommentRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java
new file mode 100644
index 00000000..7fa285a8
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java
@@ -0,0 +1,35 @@
+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, c.id ASC")
+ Page findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable);
+
+ 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")
+ 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);
+
+ @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/EntityTagCount.java b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java
new file mode 100644
index 00000000..1bad34a4
--- /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
new file mode 100644
index 00000000..90eea819
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java
@@ -0,0 +1,34 @@
+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;
+
+public interface PostEntityTagRepository extends JpaRepository {
+
+ List findByPostId(Long postId);
+
+ List 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/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java
new file mode 100644
index 00000000..8b833e07
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java
@@ -0,0 +1,13 @@
+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 {
+
+ boolean existsByUserIdAndPostId(Long userId, Long postId);
+
+ long deleteByUserIdAndPostId(Long userId, Long postId);
+
+ void deleteByPostId(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
new file mode 100644
index 00000000..3e09e461
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java
@@ -0,0 +1,61 @@
+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;
+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;
+
+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);
+
+ @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);
+
+ @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);
+
+ @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/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java
new file mode 100644
index 00000000..31a41381
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java
@@ -0,0 +1,210 @@
+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 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) {
+ 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 = topLevelIds.isEmpty()
+ ? Map.of()
+ : commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(topLevelIds).stream()
+ .collect(Collectors.groupingBy(Comment::getParentCommentId));
+
+ List allComments = Stream.concat(
+ topLevelComments.stream(),
+ repliesByParentId.values().stream().flatMap(List::stream)
+ ).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()),
+ 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) {
+ if (!postRepository.existsById(postId)) {
+ throw new PostNotFoundException();
+ }
+
+ 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);
+ // commentCount는 생성 시에만 증가하는 단조 카운터다. 삭제는 항상 소프트 삭제(행 유지)라 감소시키지 않는다.
+ 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 (comment.isDeleted()) {
+ throw new CommentNotFoundException();
+ }
+ 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(commentRepository.findLikeCountById(commentId));
+ }
+
+ /**
+ * 댓글 좋아요를 취소한다. 좋아요한 적 없으면 NotLikedException을 던진다.
+ */
+ @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(commentRepository.findLikeCountById(commentId));
+ }
+
+ 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();
+
+ String authorNickname = nicknameByUserId.get(comment.getUserId());
+ Boolean isLiked = userId == null || comment.isDeleted() ? null : likedCommentIds.contains(comment.getId());
+
+ return new CommentResponse(
+ comment.getId(),
+ authorNickname,
+ comment.isVisibleAuthor(userId),
+ comment.getDisplayContent(),
+ comment.getLikeCount(),
+ isLiked,
+ 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/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 00000000..b65d8504
--- /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());
+ }
+
+ EntityCardResponse toCard(Concert concert) {
+ String subtitle = concert.getStartDate() + " · " + concert.getVenueName();
+ return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl());
+ }
+
+ EntityCardResponse toCard(Artist artist) {
+ return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl());
+ }
+
+ 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/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 00000000..131b844c
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java
@@ -0,0 +1,83 @@
+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 "%" + 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
new file mode 100644
index 00000000..b8c9bc9b
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/service/PostService.java
@@ -0,0 +1,383 @@
+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.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;
+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.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;
+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.dao.DataIntegrityViolationException;
+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;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+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 CommentRepository commentRepository;
+ private final CommentLikeRepository commentLikeRepository;
+ 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();
+
+ 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가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게
+ * 조회 시점에 EntityLookupService가 조용히 제외한다.
+ */
+ @Transactional
+ public PostCreateResponse create(Long userId, PostCreateRequest request) {
+ List tags = request.entityTags() == null ? List.of() : request.entityTags();
+
+ 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(content)
+ .contentText(contentText)
+ .recommendCount(0L)
+ .viewCount(0L)
+ .commentCount(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,
+ post.getCommentCount(),
+ 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();
+ }
+
+ /**
+ * 특정 엔티티(공연·아티스트·발매)에 태그된 게시글을 백링크로 조회한다.
+ *
+ * @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());
+ }
+
+ /**
+ * 최근 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();
+ }
+
+ /**
+ * 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정.
+ */
+ public PageResponse search(String q, int page, int size) {
+ Pageable pageable = PageRequest.of(page, size);
+ 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());
+ }
+
+ /**
+ * 게시글을 수정한다. 작성자 본인만 수정할 수 있다.
+ * category·title·content는 null이면 기존값을 유지하고, entityTags는 null이면 기존 태그를 유지한다.
+ * entityTags가 주어지면 기존 태그를 전체 삭제 후 재삽입한다.
+ */
+ @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();
+ }
+
+ String contentText = request.content() != null ? TiptapTextExtractor.extract(request.content()) : null;
+ if (contentText != null) {
+ 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) {
+ 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();
+ }
+ commentLikeRepository.deleteByCommentPostId(id);
+ commentRepository.deleteByPostId(id);
+ postRecommendRepository.deleteByPostId(id);
+ postEntityTagRepository.deleteByPostId(id);
+ postRepository.delete(post);
+ }
+
+ /**
+ * 게시글을 추천한다. 이미 추천한 게시글이면 AlreadyRecommendedException을 던진다.
+ */
+ @Transactional
+ public RecommendCountResponse recommend(Long userId, Long id) {
+ 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(postRepository.findRecommendCountById(id));
+ }
+
+ /**
+ * 게시글 추천을 취소한다. 추천한 적 없으면 NotRecommendedException을 던진다.
+ */
+ @Transactional
+ public RecommendCountResponse unrecommend(Long userId, Long id) {
+ postRepository.findById(id).orElseThrow(PostNotFoundException::new);
+ long deletedCount = postRecommendRepository.deleteByUserIdAndPostId(userId, id);
+ if (deletedCount == 0) {
+ throw new NotRecommendedException();
+ }
+ postRepository.decrementRecommendCount(id);
+ return new RecommendCountResponse(postRepository.findRecommendCountById(id));
+ }
+
+ private void validateContentTextLength(String contentText) {
+ if (contentText.length() > MAX_CONTENT_TEXT_LENGTH) {
+ throw new PostContentTooLongException();
+ }
+ }
+
+ 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);
+ } 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 saveEntityTags(Long postId, List tags) {
+ tags.stream().distinct().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 00000000..3455b010
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/util/TiptapTextExtractor.java
@@ -0,0 +1,46 @@
+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;
+ }
+ if (isTextNode(map)) {
+ if (map.get("text") instanceof String 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) {
+ return "text".equals(node.get("type"));
+ }
+}
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 00000000..84faf5c0
--- /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)
+);
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 00000000..77719053
--- /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;
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 00000000..b6dd0554
--- /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/EntityPostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/EntityPostControllerTest.java
new file mode 100644
index 00000000..07f3cbfc
--- /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/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java
new file mode 100644
index 00000000..154438ff
--- /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/controller/PostCommentControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java
new file mode 100644
index 00000000..7a39d1ac
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/controller/PostCommentControllerTest.java
@@ -0,0 +1,227 @@
+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_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
+ 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());
+ }
+}
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 00000000..615eecd8
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java
@@ -0,0 +1,441 @@
+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;
+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.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.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;
+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 java.util.stream.IntStream;
+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());
+ }
+
+ private List entityTags(int count) {
+ return IntStream.rangeClosed(1, count)
+ .mapToObj(i -> new EntityTagRequest(EntityType.ARTIST, (long) i))
+ .toList();
+ }
+
+ // -------------------------------------------------------------------------
+ // 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());
+ }
+
+ @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_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
+ 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}
+ // -------------------------------------------------------------------------
+
+ @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, 0L, 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));
+ }
+
+ // -------------------------------------------------------------------------
+ // 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}
+ // -------------------------------------------------------------------------
+
+ @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());
+ }
+
+ @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
+ 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}
+ // -------------------------------------------------------------------------
+
+ @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());
+ }
+
+ // -------------------------------------------------------------------------
+ // 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/controller/SearchControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/SearchControllerTest.java
new file mode 100644
index 00000000..3d15e8f9
--- /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/CommentRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java
new file mode 100644
index 00000000..9d6d646b
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/repository/CommentRepositoryTest.java
@@ -0,0 +1,215 @@
+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.findByParentCommentIdInOrderByCreatedAtAscIdAsc(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.findByParentCommentIdInOrderByCreatedAtAscIdAsc(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();
+ }
+
+ @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/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java
new file mode 100644
index 00000000..43e286c1
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java
@@ -0,0 +1,150 @@
+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)
+ .commentCount(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_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
+ 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/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java
new file mode 100644
index 00000000..240a29cf
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java
@@ -0,0 +1,448 @@
+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.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.existsById(POST_ID)).willReturn(false);
+
+ // 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
+ 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.existsById(POST_ID)).willReturn(true);
+ given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage);
+ 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, "뷔")));
+
+ // 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
+ 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.existsById(POST_ID)).willReturn(true);
+ given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage);
+ given(commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(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
+ 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.existsById(POST_ID)).willReturn(true);
+ given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage);
+ 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)))
+ .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_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);
+ Pageable pageable = PageRequest.of(0, 20);
+ Page topLevelPage = new PageImpl<>(List.of(deletedTopComment), pageable, 1);
+
+ given(postRepository.existsById(POST_ID)).willReturn(true);
+ given(commentRepository.findTopLevelByPostId(POST_ID, pageable)).willReturn(topLevelPage);
+ 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, "뷔")));
+ given(commentLikeRepository.findLikedCommentIds(VIEWER_ID, List.of(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()).isEqualTo("지민");
+ assertThat(topResponse.likeCount()).isEqualTo(3L);
+ 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.existsById(POST_ID)).willReturn(false);
+ 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
+ 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);
+ 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
+ given(postRepository.existsById(POST_ID)).willReturn(true);
+ 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
+ Comment parent = buildComment(PARENT_ID, OTHER_POST_ID, AUTHOR_ID, null, "다른 게시글 댓글", 0L, false);
+ given(postRepository.existsById(POST_ID)).willReturn(true);
+ 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
+ Comment parent = buildComment(PARENT_ID, POST_ID, AUTHOR_ID, COMMENT_ID, "이미 답글인 댓글", 0L, false);
+ given(postRepository.existsById(POST_ID)).willReturn(true);
+ 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_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);
+
+ // 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_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
+ 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));
+ given(commentRepository.findLikeCountById(COMMENT_ID)).willReturn(4L);
+
+ // 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);
+ }
+}
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 00000000..b5f5fa43
--- /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();
+ }
+}
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 00000000..b31e85a2
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java
@@ -0,0 +1,136 @@
+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();
+ }
+
+ @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
new file mode 100644
index 00000000..fa25fa8d
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java
@@ -0,0 +1,975 @@
+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.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;
+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.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;
+import com.Coming.Backend.post.repository.PostRepository;
+import java.time.LocalDateTime;
+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.dao.DataIntegrityViolationException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+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 CommentRepository commentRepository;
+
+ @Mock
+ private CommentLikeRepository commentLikeRepository;
+
+ @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", "안녕하세요")
+ ))
+ )
+ );
+ }
+
+ /**
+ * "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))
+ ))
+ )
+ );
+ }
+
+ /**
+ * 텍스트 노드는 짧지만(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)
+ .userId(userId)
+ .category(category)
+ .title(title)
+ .content("{\"type\":\"doc\"}")
+ .contentText("기존 텍스트")
+ .recommendCount(0L)
+ .viewCount(viewCount)
+ .commentCount(0L)
+ .build();
+ }
+
+ private User buildUser(Long id, String nickname) {
+ return User.builder().id(id).nickname(nickname).build();
+ }
+
+ // -------------------------------------------------------------------------
+ // create
+ // -------------------------------------------------------------------------
+
+ @Test
+ 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
+ PostCreateResponse response = postService.create(AUTHOR_ID, request);
+
+ // then
+ assertThat(response.id()).isEqualTo(POST_ID);
+ verify(postEntityTagRepository, never()).save(any(PostEntityTag.class));
+ }
+
+ @Test
+ 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
+ PostCreateResponse response = postService.create(AUTHOR_ID, request);
+
+ // then
+ assertThat(response.id()).isEqualTo(POST_ID);
+ verify(postEntityTagRepository, never()).save(any(PostEntityTag.class));
+ }
+
+ @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));
+ }
+
+ @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
+ 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));
+ }
+
+ @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
+ // -------------------------------------------------------------------------
+
+ @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);
+ }
+
+ // -------------------------------------------------------------------------
+ // 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();
+ }
+
+ // -------------------------------------------------------------------------
+ // 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
+ // -------------------------------------------------------------------------
+
+ @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();
+ }
+
+ @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
+ // -------------------------------------------------------------------------
+
+ @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_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));
+ PostUpdateRequest request = new PostUpdateRequest(PostCategory.REVIEW, null, null, null);
+
+ // when
+ postService.update(AUTHOR_ID, POST_ID, request);
+
+ // then
+ 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
+ // -------------------------------------------------------------------------
+
+ @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);
+ }
+
+ @Test
+ void should_delete_comments_comment_likes_and_recommends_when_author_deletes_post() {
+ // 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(commentLikeRepository).deleteByCommentPostId(POST_ID);
+ verify(commentRepository).deleteByPostId(POST_ID);
+ verify(postRecommendRepository).deleteByPostId(POST_ID);
+ }
+
+ // -------------------------------------------------------------------------
+ // 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);
+ given(postRepository.findRecommendCountById(POST_ID)).willReturn(4L);
+
+ // 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.deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(0L);
+
+ // 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();
+ given(postRepository.findById(POST_ID)).willReturn(Optional.of(post));
+ given(postRecommendRepository.deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID)).willReturn(1L);
+ given(postRepository.findRecommendCountById(POST_ID)).willReturn(2L);
+
+ // when
+ RecommendCountResponse response = postService.unrecommend(OTHER_USER_ID, POST_ID);
+
+ // then
+ assertThat(response.recommendCount()).isEqualTo(2L);
+ verify(postRecommendRepository).deleteByUserIdAndPostId(OTHER_USER_ID, POST_ID);
+ verify(postRepository).decrementRecommendCount(POST_ID);
+ }
+}
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 00000000..8b95e46f
--- /dev/null
+++ b/src/test/java/com/Coming/Backend/post/util/TiptapTextExtractorTest.java
@@ -0,0 +1,113 @@
+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_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", "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();
+ }
+}