From 44bc94d0d88aad792b92f1e7057f1961797f4fa5 Mon Sep 17 00:00:00 2001 From: myqewr Date: Sat, 17 May 2025 02:30:22 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20api=20=EC=B6=94=EA=B0=80(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/user-api.adoc | 22 +- .../controller/DeleteBookmarkController.java | 29 +++ .../dto/request/DeleteBookmarkRequest.java | 9 + .../user/UserDomainPersistenceAdapter.java | 13 ++ .../repository/BookmarkRepository.java | 7 + .../user/DeleteBookmarkByIdCommand.java | 12 ++ .../command/user/DeleteBookmarkCommand.java | 13 ++ .../port/in/user/DeleteBookmarkUseCase.java | 9 + .../persistence/user/DeleteBookmarkPort.java | 3 + .../persistence/user/LoadBookmarkPort.java | 4 + .../service/user/DeleteBookmarkService.java | 36 ++++ .../response/enums/ErrorResponseCode.java | 1 + .../ftm/server/user/DeleteBookmarkTest.java | 196 ++++++++++++++++++ 13 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ftm/server/adapter/in/web/user/controller/DeleteBookmarkController.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/user/dto/request/DeleteBookmarkRequest.java create mode 100644 src/main/java/com/ftm/server/application/command/user/DeleteBookmarkByIdCommand.java create mode 100644 src/main/java/com/ftm/server/application/command/user/DeleteBookmarkCommand.java create mode 100644 src/main/java/com/ftm/server/application/port/in/user/DeleteBookmarkUseCase.java create mode 100644 src/main/java/com/ftm/server/application/service/user/DeleteBookmarkService.java create mode 100644 src/test/java/com/ftm/server/user/DeleteBookmarkTest.java diff --git a/src/docs/asciidoc/user-api.adoc b/src/docs/asciidoc/user-api.adoc index f2302bd..381dc33 100644 --- a/src/docs/asciidoc/user-api.adoc +++ b/src/docs/asciidoc/user-api.adoc @@ -266,4 +266,24 @@ include::{snippetsDir}/loadMyBookmarkPosts/3/http-response.adoc[] 실패 3. 요청 페이지당 개수 유효성 검증에 실패할 경우 -include::{snippetsDir}/loadMyBookmarkPosts/4/http-response.adoc[] \ No newline at end of file +include::{snippetsDir}/loadMyBookmarkPosts/4/http-response.adoc[] + +=== **13. 북마크 삭제 api** + +사용자가 등록한 북마크를 삭제합니다. + +==== Request +include::{snippetsDir}/deleteBookmark/1/http-request.adoc[] + +==== Request Body Fields +include::{snippetsDir}/deleteBookmark/1/request-fields.adoc[] + +==== 성공 Response +include::{snippetsDir}/deleteBookmark/1/http-response.adoc[] + +==== 실패 Response +실패 1. 해당 사용자와 게시글로 구성된 북마크가 존재하지 않음. +include::{snippetsDir}/deleteBookmark/2/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/deleteBookmark/2/response-fields.adoc[] \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/controller/DeleteBookmarkController.java b/src/main/java/com/ftm/server/adapter/in/web/user/controller/DeleteBookmarkController.java new file mode 100644 index 0000000..4989053 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/controller/DeleteBookmarkController.java @@ -0,0 +1,29 @@ +package com.ftm.server.adapter.in.web.user.controller; + +import com.ftm.server.adapter.in.web.user.dto.request.DeleteBookmarkRequest; +import com.ftm.server.application.command.user.DeleteBookmarkCommand; +import com.ftm.server.application.port.in.user.DeleteBookmarkUseCase; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.infrastructure.security.UserPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class DeleteBookmarkController { + + private final DeleteBookmarkUseCase deleteBookmarkUseCase; + + @DeleteMapping("api/users/bookmarks") + public ResponseEntity deleteBookmark( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody DeleteBookmarkRequest request) { + deleteBookmarkUseCase.execute( + DeleteBookmarkCommand.of(userPrincipal.getId(), request.getPostId())); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(null); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/dto/request/DeleteBookmarkRequest.java b/src/main/java/com/ftm/server/adapter/in/web/user/dto/request/DeleteBookmarkRequest.java new file mode 100644 index 0000000..72ddc17 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/dto/request/DeleteBookmarkRequest.java @@ -0,0 +1,9 @@ +package com.ftm.server.adapter.in.web.user.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class DeleteBookmarkRequest { + @NotNull private final Long postId; +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java index 5bc1a12..c3609e8 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java @@ -255,6 +255,11 @@ public void deleteBookmarkByUserList(DeleteBookmarkByUserIdCommand command) { bookmarkRepository.deleteAllByUserIdList(command.getUserIdList()); } + @Override + public void deleteBookmarkById(DeleteBookmarkByIdCommand command) { + bookmarkRepository.deleteAllById(List.of(command.getBookmarkId())); + } + @Override public Boolean saveBookmark(Bookmark bookmark) { // 이미 생성된 북마크인 경우 -> false @@ -287,4 +292,12 @@ public Slice loadBookmarksByUserIdWithPaging(FindBookmarksByPagingQuer .findAllByUserIdWithPaging(query) .map(bookmarkMapper::toDomainEntity); } + + @Override + public Optional loadBookmarkByUserIdAndPostId( + FindBookmarkByUserIdAndPostIdQuery query) { + Optional bookmarkJpaEntity = + bookmarkRepository.findByUserIdAndPostId(query.getUserId(), query.getPostId()); + return bookmarkJpaEntity.map(bookmarkMapper::toDomainEntity); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/BookmarkRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/BookmarkRepository.java index 0fafa45..d2eeac7 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/BookmarkRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/BookmarkRepository.java @@ -2,6 +2,7 @@ import com.ftm.server.adapter.out.persistence.model.BookmarkJpaEntity; 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; @@ -24,4 +25,10 @@ public interface BookmarkRepository int saveOrUpdate(@Param("userId") Long userId, @Param("postId") Long postId); Boolean existsByUserIdAndPostId(Long userId, Long postId); + + @Query( + value = + "select b from BookmarkJpaEntity b where b.user.id =:userId and b.post.id =:postId") + Optional findByUserIdAndPostId( + @Param(value = "userId") Long userId, @Param(value = "postId") Long postId); } diff --git a/src/main/java/com/ftm/server/application/command/user/DeleteBookmarkByIdCommand.java b/src/main/java/com/ftm/server/application/command/user/DeleteBookmarkByIdCommand.java new file mode 100644 index 0000000..bfee8b6 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/user/DeleteBookmarkByIdCommand.java @@ -0,0 +1,12 @@ +package com.ftm.server.application.command.user; + +import lombok.Data; + +@Data +public class DeleteBookmarkByIdCommand { + private final Long bookmarkId; + + public static DeleteBookmarkByIdCommand of(Long bookmarkId) { + return new DeleteBookmarkByIdCommand(bookmarkId); + } +} diff --git a/src/main/java/com/ftm/server/application/command/user/DeleteBookmarkCommand.java b/src/main/java/com/ftm/server/application/command/user/DeleteBookmarkCommand.java new file mode 100644 index 0000000..fcbfc93 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/user/DeleteBookmarkCommand.java @@ -0,0 +1,13 @@ +package com.ftm.server.application.command.user; + +import lombok.Data; + +@Data +public class DeleteBookmarkCommand { + private final Long userId; + private final Long postId; + + public static DeleteBookmarkCommand of(Long userId, Long postId) { + return new DeleteBookmarkCommand(userId, postId); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/user/DeleteBookmarkUseCase.java b/src/main/java/com/ftm/server/application/port/in/user/DeleteBookmarkUseCase.java new file mode 100644 index 0000000..a8303d3 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/user/DeleteBookmarkUseCase.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.port.in.user; + +import com.ftm.server.application.command.user.DeleteBookmarkCommand; +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface DeleteBookmarkUseCase { + void execute(DeleteBookmarkCommand command); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/DeleteBookmarkPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/DeleteBookmarkPort.java index f73eaaa..c93223b 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/user/DeleteBookmarkPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/DeleteBookmarkPort.java @@ -1,9 +1,12 @@ package com.ftm.server.application.port.out.persistence.user; +import com.ftm.server.application.command.user.DeleteBookmarkByIdCommand; import com.ftm.server.application.command.user.DeleteBookmarkByUserIdCommand; import com.ftm.server.common.annotation.Port; @Port public interface DeleteBookmarkPort { void deleteBookmarkByUserList(DeleteBookmarkByUserIdCommand command); + + void deleteBookmarkById(DeleteBookmarkByIdCommand command); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadBookmarkPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadBookmarkPort.java index 90d94a4..6060df2 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadBookmarkPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadBookmarkPort.java @@ -1,12 +1,16 @@ package com.ftm.server.application.port.out.persistence.user; +import com.ftm.server.application.query.FindBookmarkByUserIdAndPostIdQuery; import com.ftm.server.application.query.FindBookmarksByPagingQuery; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.Bookmark; +import java.util.Optional; import org.springframework.data.domain.Slice; @Port public interface LoadBookmarkPort { Slice loadBookmarksByUserIdWithPaging(FindBookmarksByPagingQuery query); + + Optional loadBookmarkByUserIdAndPostId(FindBookmarkByUserIdAndPostIdQuery query); } diff --git a/src/main/java/com/ftm/server/application/service/user/DeleteBookmarkService.java b/src/main/java/com/ftm/server/application/service/user/DeleteBookmarkService.java new file mode 100644 index 0000000..8f949d5 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/user/DeleteBookmarkService.java @@ -0,0 +1,36 @@ +package com.ftm.server.application.service.user; + +import com.ftm.server.application.command.user.DeleteBookmarkByIdCommand; +import com.ftm.server.application.command.user.DeleteBookmarkCommand; +import com.ftm.server.application.port.in.user.DeleteBookmarkUseCase; +import com.ftm.server.application.port.out.persistence.user.DeleteBookmarkPort; +import com.ftm.server.application.port.out.persistence.user.LoadBookmarkPort; +import com.ftm.server.application.query.FindBookmarkByUserIdAndPostIdQuery; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.Bookmark; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DeleteBookmarkService implements DeleteBookmarkUseCase { + + private final LoadBookmarkPort loadBookmarkPort; + private final DeleteBookmarkPort deleteBookmarkPort; + + @Override + @Transactional + public void execute(DeleteBookmarkCommand command) { + Bookmark bookmark = + loadBookmarkPort + .loadBookmarkByUserIdAndPostId( + FindBookmarkByUserIdAndPostIdQuery.of( + command.getUserId(), command.getPostId())) + .orElseThrow( + () -> new CustomException(ErrorResponseCode.BOOKMARK_NOT_FOUND)); + + deleteBookmarkPort.deleteBookmarkById(DeleteBookmarkByIdCommand.of(bookmark.getId())); + } +} diff --git a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java index 7d6ff8d..fbd29c8 100644 --- a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java +++ b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java @@ -51,6 +51,7 @@ public enum ErrorResponseCode { POST_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_005", "요청한 게시글을 찾을 수 없습니다."), POST_PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_006", "요청한 상품을 찾을 수 없습니다."), POST_PRODUCT_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_007", "요청한 상품 이미지를 찾을 수 없습니다."), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_008", "요청된 사용자와 게시글에 부합하는 북마크를 찾을 수 없습니다."), // 409번 USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "E409_001", "이미 존재하는 사용자입니다."), diff --git a/src/test/java/com/ftm/server/user/DeleteBookmarkTest.java b/src/test/java/com/ftm/server/user/DeleteBookmarkTest.java new file mode 100644 index 0000000..94db190 --- /dev/null +++ b/src/test/java/com/ftm/server/user/DeleteBookmarkTest.java @@ -0,0 +1,196 @@ +package com.ftm.server.user; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.adapter.in.web.user.dto.request.DeleteBookmarkRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.out.persistence.post.SavePostPort; +import com.ftm.server.application.port.out.persistence.user.SaveBookmarkPort; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.Bookmark; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.GroomingCategory; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class DeleteBookmarkTest extends BaseTest { + + @Autowired private PlatformTransactionManager transactionManager; + + private TransactionStatus transactionStatus; + + @Autowired private SavePostPort savePostPort; + @Autowired private SaveBookmarkPort saveBookmarkPort; + + private static MockHttpSession session; + private static User user; + + private final List requestFieldDescriptors = + List.of(fieldWithPath("postId").type(NUMBER).description("게시글 id")); + + private final List responseFieldDescriptors = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"), + fieldWithPath("data") + .type(JsonFieldType.OBJECT) + .optional() + .description("data")); + + private ResultActions getResultActions(MockHttpSession session, Long postId) throws Exception { + return mockMvc.perform( // api 실행 + RestDocumentationRequestBuilders.delete("/api/users/bookmarks") + .contentType(MediaType.APPLICATION_JSON) + .content( + new ObjectMapper() + .writeValueAsString(new DeleteBookmarkRequest(postId))) + .session(session)); + } + + // 문서화 반환 함수 + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "deleteBookmark/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + responseFields(responseFieldDescriptors), + requestFields(requestFieldDescriptors), + resource( + ResourceSnippetParameters.builder() + .tag("회원") + .summary("북마크 삭제 api") + .description("북마크를 삭제합니다.") + .responseFields(responseFieldDescriptors) + .requestFields(requestFieldDescriptors) + .build())); + } + + private RestDocumentationResultHandler getDocumentWithoutResponse(Integer identifier) { + return document( + "deleteBookmark/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + requestFields(requestFieldDescriptors), + resource( + ResourceSnippetParameters.builder() + .tag("회원") + .summary("북마크 삭제 api") + .description("북마크를 삭제합니다.") + .requestFields(requestFieldDescriptors) + .build())); + } + + @BeforeAll + public void startTransaction() { + // 수동으로 트랜잭션 시작 + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED); + // 트랜잭션 시작 + transactionStatus = transactionManager.getTransaction(def); + + SessionAndUser sessionAndUser = createUserAndLoginAndReturnUser(); // 로그인 처리 + session = sessionAndUser.mockHttpSession(); + user = sessionAndUser.user(); + + // test 용 post 생성 + Post post = + savePostPort.savePost( + Post.create( + SavePostCommand.from( + user.getId(), + new SavePostRequest( + "test", + GroomingCategory.FASHION, + new ArrayList<>(), + "content", + new ArrayList<>()), + new ArrayList<>(), + new ArrayList<>()))); + } + + @AfterAll + public void rollbackTransaction() { + // 트랜잭션 롤백 + if (transactionStatus != null) { + transactionManager.rollback(transactionStatus); + } + } + + @Test + @Transactional + @DisplayName("테스트 성공") + void test1() throws Exception { + // given + // test 용 post 생성 + Post post = + savePostPort.savePost( + Post.create( + SavePostCommand.from( + user.getId(), + new SavePostRequest( + "test", + GroomingCategory.FASHION, + new ArrayList<>(), + "content", + new ArrayList<>()), + new ArrayList<>(), + new ArrayList<>()))); + // 테스트 용 북마크 생성 + saveBookmarkPort.saveBookmark(Bookmark.createBookmark(user.getId(), post.getId())); + + // when + ResultActions resultActions = getResultActions(session, post.getId()); + + // then + resultActions.andExpect(status().isNoContent()); + + // documentation + resultActions.andDo(getDocumentWithoutResponse(1)); + } + + @Test + @Transactional + @DisplayName("테스트 실패") + void test2() throws Exception { + + // when + ResultActions resultActions = getResultActions(session, 1000L); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.BOOKMARK_NOT_FOUND.getHttpStatus().value())) + .andExpect(jsonPath("code").value(ErrorResponseCode.BOOKMARK_NOT_FOUND.getCode())); + + // documentation + resultActions.andDo(getDocument(2)); + } +}