Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,8 @@ include::overview/공통-개발-참고-사항.adoc[]

- 특정 피드에 댓글 작성 API #POST# `/api/v1/feeds/{feedId}/comments`

- 댓글 삭제 API #DELETE# `/api/v1/comments/{commentId}`
- 댓글 삭제 API #DELETE# `/api/v1/comments/{commentId}`

=== xref:좋아요-API.html[좋아요 API]

- 특정 댓글의 좋아요 작성 API #POST# `/api/v1/comments/{commentId}/like`
32 changes: 32 additions & 0 deletions src/docs/asciidoc/like/특정-댓글의-좋아요-작성-API.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
== 특정 댓글의 좋아요 작성 API

=== 설명

- #POST# `/api/v1/comments/{commentId}/like`
- 특정 댓글에 좋아요를 하거나 취소하는 API 입니다.
- `status` 필드가 `true`이면 좋아요, `false`이면 좋아요 취소입니다.

=== 개발 이력

- Sprint 24 (2026-02-04): 기능 개발이 완료되었습니다.

=== 개발 참고 사항

- <<공통-개발-참고-사항,공통 개발 참고 사항>>을 참고하세요.

=== 코드 샘플

operation::create-comment-like/create-comment-like_success[snippets="curl-request,http-request,http-response"]

=== 매개 변수

operation::create-comment-like/create-comment-like_success[snippets="request-headers,path-parameters,request-fields,response-fields"]

==== 응답 상태 코드

|===
|상태 코드|설명
|201|리소스가 성공적으로 생성되었습니다. (좋아요 성공/취소 모두 성공 응답)
|401|인증되지 않은 요청입니다. Access Token이 없거나 유효하지 않습니다.
|404|존재하지 않는 댓글입니다.
|===
22 changes: 22 additions & 0 deletions src/docs/asciidoc/좋아요-API.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
= 좋아요 API
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:seclinks:

:operation-request-headers-title: 요청 헤더
:operation-path-parameters-title: 경로 매개 변수
:operation-request-parts-title: 요청 파트(Form Data)
:operation-request-fields-title: 요청 필드
:operation-response-fields-title: 응답 필드
:operation-curl-request-title: Curl 요청 예시
:operation-http-request-title: HTTP 요청 예시
:operation-http-response-title: HTTP 응답 예시

[[공통-개발-참고-사항]]
== 공통 개발 참고 사항

include::overview/공통-개발-참고-사항.adoc[]

include::like/특정-댓글의-좋아요-작성-API.adoc[]
10 changes: 10 additions & 0 deletions src/main/java/com/project200/undabang/comment/entity/Comment.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ public boolean isDeleted() {
return this.deletedAt != null;
}

public void incrementLikesCount() {
this.likesCount++;
}

public void decrementLikesCount() {
if (this.likesCount > 0) {
this.likesCount--;
}
}

public static Comment create(Member member, Feed feed, Comment parent, CreateCommentRequest request) {
return Comment.builder()
.member(member)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.project200.undabang.like.controller;

import com.project200.undabang.common.web.response.CommonResponse;
import com.project200.undabang.like.dto.request.CreateCommentLikeRequest;
import com.project200.undabang.like.dto.response.CreateCommentLikeResponse;
import com.project200.undabang.like.service.impl.CommentCommandLikeServiceImpl;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentLikeCommandController {

private final CommentCommandLikeServiceImpl commentCommandLikeService;

@PostMapping("/v1/comments/{commentId}/like")
public ResponseEntity<CommonResponse<CreateCommentLikeResponse>> createCommentLike(@PathVariable Long commentId,
@Valid @RequestBody CreateCommentLikeRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(CommonResponse.create(commentCommandLikeService.createCommentLike(commentId, request)));

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.project200.undabang.like.dto.request;

import jakarta.validation.constraints.NotNull;

public record CreateCommentLikeRequest(
Comment thread
dlchdaud123 marked this conversation as resolved.
@NotNull(message = "좋아요 여부는 필수 값입니다.")
Boolean liked) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.project200.undabang.like.dto.response;

public record CreateCommentLikeResponse(
Boolean liked,
Integer likesCount) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ public class CommentLike {
@Builder.Default
@Column(name = "comment_like_created_at", nullable = false, updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();

public static CommentLike create(Comment comment, Member member) {
return CommentLike.builder()
.comment(comment)
.member(member)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package com.project200.undabang.like.repository;

import com.project200.undabang.comment.entity.Comment;
import com.project200.undabang.like.entity.CommentLike;
import com.project200.undabang.member.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface CommentLikeRepository extends JpaRepository<CommentLike, Long> {
Optional<CommentLike> findByCommentAndMember(Comment comment, Member member);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.project200.undabang.like.service;

import com.project200.undabang.like.dto.request.CreateCommentLikeRequest;
import com.project200.undabang.like.dto.response.CreateCommentLikeResponse;

public interface CommentCommandLikeService {

CreateCommentLikeResponse createCommentLike(Long CommentId, CreateCommentLikeRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.project200.undabang.like.service.impl;

import com.project200.undabang.comment.entity.Comment;
import com.project200.undabang.comment.repository.CommentRepository;
import com.project200.undabang.common.context.UserContextHolder;
import com.project200.undabang.common.web.exception.CustomException;
import com.project200.undabang.common.web.exception.ErrorCode;
import com.project200.undabang.like.dto.request.CreateCommentLikeRequest;
import com.project200.undabang.like.dto.response.CreateCommentLikeResponse;
import com.project200.undabang.like.entity.CommentLike;
import com.project200.undabang.like.repository.CommentLikeRepository;
import com.project200.undabang.like.service.CommentCommandLikeService;
import com.project200.undabang.member.entity.Member;
import com.project200.undabang.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;
import java.util.UUID;

@Service
@RequiredArgsConstructor
@Transactional
public class CommentCommandLikeServiceImpl implements CommentCommandLikeService {

private final CommentLikeRepository commentLikeRepository;
private final MemberRepository memberRepository;
private final CommentRepository commentRepository;

@Override
public CreateCommentLikeResponse createCommentLike(Long commentId, CreateCommentLikeRequest request) {
UUID currentUserId = UserContextHolder.getUserId();

// 댓글 존재 여부 검증
Comment comment = commentRepository.findByIdAndDeletedAtIsNull(commentId)
.orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND));

// 현재 사용자 조회
Member member = memberRepository.findByMemberIdAndMemberDeletedAtNull(currentUserId)
.orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));

// 댓글 좋아요 생성
Optional<CommentLike> existingLike = commentLikeRepository.findByCommentAndMember(comment, member);

if (Boolean.TRUE.equals(request.liked())) {
// 좋아요 생성: 이미 존재하면 아무것도 안 함
if (existingLike.isEmpty()) {
commentLikeRepository.save(CommentLike.create(comment, member));
comment.incrementLikesCount();
}
} else {
// 좋아요 취소: 존재하면 삭제
existingLike.ifPresent(like -> {
commentLikeRepository.delete(like);
comment.decrementLikesCount();
});
}
Comment thread
dlchdaud123 marked this conversation as resolved.
return new CreateCommentLikeResponse(request.liked(), comment.getLikesCount());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.project200.undabang.like.controller;

import com.project200.undabang.common.web.exception.CustomException;
import com.project200.undabang.common.web.exception.ErrorCode;
import com.project200.undabang.common.web.response.CommonResponse;
import com.project200.undabang.configuration.AbstractRestDocSupport;
import com.project200.undabang.like.dto.request.CreateCommentLikeRequest;
import com.project200.undabang.like.dto.response.CreateCommentLikeResponse;
import com.project200.undabang.like.service.impl.CommentCommandLikeServiceImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import java.util.UUID;

import static com.project200.undabang.configuration.DocumentFormatGenerator.getTypeFormat;
import static com.project200.undabang.configuration.HeadersGenerator.getCommonApiHeaders;
import static com.project200.undabang.configuration.RestDocsUtils.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(CommentLikeCommandController.class)
@DisplayName("CommentCommandLikeController 테스트")
class CommentLikeCommandControllerTest extends AbstractRestDocSupport {

@MockitoBean
private CommentCommandLikeServiceImpl commentCommandLikeService;

@Nested
@DisplayName("createCommentLike 메소드는")
class CreateCommentLike {

@Test
@DisplayName("댓글 좋아요를 성공한다")
void createCommentLike_success() throws Exception {
// given
UUID testMemberId = UUID.randomUUID();
Long commentId = 5L;
CreateCommentLikeRequest request = new CreateCommentLikeRequest(true);
CreateCommentLikeResponse responseDto = new CreateCommentLikeResponse(true, 1);

BDDMockito
.given(commentCommandLikeService.createCommentLike(BDDMockito.eq(commentId),
BDDMockito.any(CreateCommentLikeRequest.class)))
.willReturn(responseDto);

// when
String response = mockMvc
.perform(MockMvcRequestBuilders
.post("/api/v1/comments/{commentId}/like", commentId)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.headers(getCommonApiHeaders(testMemberId))
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andDo(document.document(
requestHeaders(HEADER_ACCESS_TOKEN),
pathParameters(
parameterWithName("commentId").attributes(
getTypeFormat(JsonFieldType.NUMBER))
.description("좋아요할 댓글의 ID")),
requestFields(
fieldWithPath("liked")
.type(JsonFieldType.BOOLEAN)
.description("좋아요 상태 (true: 좋아요, false: 좋아요 취소)")),
responseFields(commonResponseFields(
fieldWithPath("data.liked")
.type(JsonFieldType.BOOLEAN)
.description("좋아요 여부"),
fieldWithPath("data.likesCount")
.type(JsonFieldType.NUMBER)
.description("댓글 좋아요 수")))))
.andReturn().getResponse().getContentAsString();

// then
CommonResponse<CreateCommentLikeResponse> expectedData = CommonResponse.create(responseDto);
String expected = objectMapper.writeValueAsString(expectedData);
assertThat(response).as("응답 본문 검증").isEqualTo(expected);
}

@Test
@DisplayName("존재하지 않는 댓글 ID로 좋아요 시도 시 실패한다")
void createCommentLike_Failed_CommentNotFound() throws Exception {
// given
UUID testMemberId = UUID.randomUUID();
Long commentId = 999L;
CreateCommentLikeRequest request = new CreateCommentLikeRequest(true);

BDDMockito
.given(commentCommandLikeService.createCommentLike(BDDMockito.eq(commentId),
BDDMockito.any(CreateCommentLikeRequest.class)))
.willThrow(new CustomException(ErrorCode.COMMENT_NOT_FOUND));

// when
mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/comments/{commentId}/like", commentId)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.headers(getCommonApiHeaders(testMemberId))
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
}
}
}
Loading