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