From 1a17092219bf3642ddbc65e35389dc7199f011b4 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 19 Jun 2025 21:07:42 +0900 Subject: [PATCH 1/7] feat: add BusinessSupportController with initial setup Signed-off-by: hgkim --- .../controller/BusinessSupportController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java diff --git a/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java new file mode 100644 index 000000000..3b55f146c --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java @@ -0,0 +1,12 @@ +package app.bottlenote.support.business.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/business-support") +@RequiredArgsConstructor +public class BusinessSupportController { +//todo : need to implement the business support controller methods +} From a17c853928f2708f25184476f1f0a44b8d2b6f53 Mon Sep 17 00:00:00 2001 From: chadongmin Date: Tue, 24 Jun 2025 20:55:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat=20:=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=AC=B8=EC=9D=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constant/BusinessResultMessage.java | 16 ++++ .../controller/BusinessSupportController.java | 57 ++++++++++- .../business/domain/BusinessSupport.java | 81 ++++++++++++++++ .../BusinessSupportPageableRequest.java | 11 +++ .../request/BusinessSupportUpsertRequest.java | 14 +++ .../response/BusinessSupportDetailItem.java | 19 ++++ .../response/BusinessSupportListResponse.java | 20 ++++ .../BusinessSupportResultResponse.java | 22 +++++ .../repository/BusinessSupportRepository.java | 15 +++ .../service/BusinessSupportService.java | 94 +++++++++++++++++++ 10 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/main/java/app/bottlenote/support/business/constant/BusinessResultMessage.java create mode 100644 src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java create mode 100644 src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportPageableRequest.java create mode 100644 src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java create mode 100644 src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportDetailItem.java create mode 100644 src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java create mode 100644 src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportResultResponse.java create mode 100644 src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java create mode 100644 src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java diff --git a/src/main/java/app/bottlenote/support/business/constant/BusinessResultMessage.java b/src/main/java/app/bottlenote/support/business/constant/BusinessResultMessage.java new file mode 100644 index 000000000..4f1c63665 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/constant/BusinessResultMessage.java @@ -0,0 +1,16 @@ +package app.bottlenote.support.business.constant; + +import lombok.Getter; + +@Getter +public enum BusinessResultMessage { + REGISTER_SUCCESS("비지니스 문의가 등록되었습니다"), + MODIFY_SUCCESS("비지니스 문의가 수정되었습니다"), + DELETE_SUCCESS("비지니스 문의가 삭제되었습니다"); + + private final String description; + + BusinessResultMessage(String description) { + this.description = description; + } +} diff --git a/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java index 3b55f146c..8787542f7 100644 --- a/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java +++ b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java @@ -1,12 +1,67 @@ package app.bottlenote.support.business.controller; +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.global.service.meta.MetaService; +import app.bottlenote.support.business.dto.request.BusinessSupportPageableRequest; +import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; +import app.bottlenote.support.business.dto.response.BusinessSupportListResponse; +import app.bottlenote.support.business.service.BusinessSupportService; +import app.bottlenote.support.help.exception.HelpException; +import app.bottlenote.user.exception.UserException; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +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.RestController; +import static app.bottlenote.user.exception.UserExceptionCode.REQUIRED_USER_ID; + @RestController @RequestMapping("/api/v1/business-support") @RequiredArgsConstructor public class BusinessSupportController { -//todo : need to implement the business support controller methods + + private final BusinessSupportService service; + + @PostMapping + public ResponseEntity register(@Valid @RequestBody BusinessSupportUpsertRequest req) { + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new UserException(REQUIRED_USER_ID)); + return GlobalResponse.ok(service.register(req, userId)); + } + + @GetMapping + public ResponseEntity getAllList(@ModelAttribute BusinessSupportPageableRequest req) { + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new HelpException(REQUIRED_USER_ID)); + PageResponse page = service.getList(req, userId); + return GlobalResponse.ok(page.content(), MetaService.createMetaInfo().add("pageable", page.cursorPageable())); + } + + @GetMapping("/{id}") + public ResponseEntity getDetail(@PathVariable Long id) { + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new HelpException(REQUIRED_USER_ID)); + BusinessSupportDetailItem item = service.getDetail(id, userId); + return GlobalResponse.ok(item); + } + + @PatchMapping("/{id}") + public ResponseEntity modify(@PathVariable Long id, @Valid @RequestBody BusinessSupportUpsertRequest req) { + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new UserException(REQUIRED_USER_ID)); + return GlobalResponse.ok(service.modify(id, req, userId)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new UserException(REQUIRED_USER_ID)); + return GlobalResponse.ok(service.delete(id, userId)); + } } diff --git a/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java new file mode 100644 index 000000000..7bb7d3e26 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java @@ -0,0 +1,81 @@ +package app.bottlenote.support.business.domain; + +import app.bottlenote.common.domain.BaseEntity; +import app.bottlenote.support.constant.StatusType; +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.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity(name = "business_support") +@Table(name = "business_supports") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BusinessSupport extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("문의자") + @Column(name = "user_id", nullable = false) + private Long userId; + + @Comment("문의 내용") + @Column(name = "content", nullable = false) + private String content; + + @Comment("연락 방식") + @Column(name = "contact_way") + private String contactWay; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private StatusType status = StatusType.WAITING; + + @Column(name = "admin_id") + private Long adminId; + + @Column(name = "response_content") + private String responseContent; + + @Builder + private BusinessSupport(Long id, Long userId, String content, String contactWay, Long adminId, String responseContent) { + this.id = id; + this.userId = userId; + this.content = content; + this.contactWay = contactWay; + this.adminId = adminId; + this.responseContent = responseContent; + } + + public static BusinessSupport create(Long userId, String content, String contactWay) { + return BusinessSupport.builder() + .userId(userId) + .content(content) + .contactWay(contactWay) + .build(); + } + + public void update(String content, String contactWay) { + this.content = content; + this.contactWay = contactWay; + } + + public void delete() { + this.status = StatusType.DELETED; + } + + public boolean isMyPost(Long userId) { + return this.userId.equals(userId); + } +} diff --git a/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportPageableRequest.java b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportPageableRequest.java new file mode 100644 index 000000000..c76e30ceb --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportPageableRequest.java @@ -0,0 +1,11 @@ +package app.bottlenote.support.business.dto.request; + +import lombok.Builder; + +public record BusinessSupportPageableRequest(Long cursor, Long pageSize) { + @Builder + public BusinessSupportPageableRequest { + cursor = cursor != null ? cursor : 0L; + pageSize = pageSize != null ? pageSize : 10L; + } +} diff --git a/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java new file mode 100644 index 000000000..132c1ac25 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java @@ -0,0 +1,14 @@ +package app.bottlenote.support.business.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record BusinessSupportUpsertRequest( + @Size(max = 500) + String content, + + String contactWay +) { +} + diff --git a/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportDetailItem.java b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportDetailItem.java new file mode 100644 index 000000000..c27294853 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportDetailItem.java @@ -0,0 +1,19 @@ +package app.bottlenote.support.business.dto.response; + +import app.bottlenote.support.constant.StatusType; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record BusinessSupportDetailItem( + Long id, + String content, + String contactWay, + LocalDateTime createAt, + StatusType status, + Long adminId, + String responseContent, + LocalDateTime lastModifyAt +) { +} diff --git a/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java new file mode 100644 index 000000000..354ace3e7 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java @@ -0,0 +1,20 @@ +package app.bottlenote.support.business.dto.response; + +import app.bottlenote.support.constant.StatusType; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +public record BusinessSupportListResponse(Long totalCount, List list) { + public static BusinessSupportListResponse of(Long totalCount, List list) { + return new BusinessSupportListResponse(totalCount, list); + } + + @Builder + public record BusinessInfo(Long id, String content, LocalDateTime createAt, StatusType status) { + public static BusinessInfo of(Long id, String content, LocalDateTime createAt, StatusType status) { + return new BusinessInfo(id, content, createAt, status); + } + } +} diff --git a/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportResultResponse.java b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportResultResponse.java new file mode 100644 index 000000000..387ffc9a5 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportResultResponse.java @@ -0,0 +1,22 @@ +package app.bottlenote.support.business.dto.response; + +import app.bottlenote.support.business.constant.BusinessResultMessage; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public record BusinessSupportResultResponse( + BusinessResultMessage codeMessage, + String message, + Long id, + String responseAt +) { + public static BusinessSupportResultResponse response(BusinessResultMessage msg, Long id) { + return new BusinessSupportResultResponse( + msg, + msg.getDescription(), + id, + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + ); + } +} diff --git a/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java b/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java new file mode 100644 index 000000000..6027ea2cf --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java @@ -0,0 +1,15 @@ +package app.bottlenote.support.business.repository; + +import app.bottlenote.support.business.domain.BusinessSupport; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BusinessSupportRepository extends JpaRepository { + Optional findTopByUserIdAndContentOrderByIdDesc(Long userId, String content); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserId(Long userId); +} diff --git a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java new file mode 100644 index 000000000..1c202dbe5 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -0,0 +1,94 @@ +package app.bottlenote.support.business.service; + + +import app.bottlenote.common.profanity.ProfanityClient; +import app.bottlenote.global.service.cursor.CursorPageable; +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.support.business.domain.BusinessSupport; +import app.bottlenote.support.business.dto.request.BusinessSupportPageableRequest; +import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; +import app.bottlenote.support.business.dto.response.BusinessSupportListResponse; +import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; +import app.bottlenote.support.business.repository.BusinessSupportRepository; +import app.bottlenote.support.help.exception.HelpException; +import app.bottlenote.user.facade.UserFacade; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static app.bottlenote.support.business.constant.BusinessResultMessage.DELETE_SUCCESS; +import static app.bottlenote.support.business.constant.BusinessResultMessage.MODIFY_SUCCESS; +import static app.bottlenote.support.business.constant.BusinessResultMessage.REGISTER_SUCCESS; +import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class BusinessSupportService { + + private final BusinessSupportRepository repository; + private final UserFacade userFacade; + private final ProfanityClient profanityClient; + + private String filter(String content) { + if (content == null || content.isBlank()) { + return ""; + } + return profanityClient.getFilteredText(content); + } + + @Transactional + public BusinessSupportResultResponse register(BusinessSupportUpsertRequest req, Long userId) { + userFacade.isValidUserId(userId); + String filtered = filter(req.content()); + repository.findTopByUserIdAndContentOrderByIdDesc(userId, filtered) + .ifPresent(bs -> { + throw new IllegalStateException("duplicate"); + }); + BusinessSupport bs = BusinessSupport.create(userId, filtered, req.contactWay()); + BusinessSupport saved = repository.save(bs); + return BusinessSupportResultResponse.response(REGISTER_SUCCESS, saved.getId()); + } + + @Transactional + public BusinessSupportResultResponse modify(Long id, BusinessSupportUpsertRequest req, Long userId) { + BusinessSupport bs = repository.findById(id).orElseThrow(() -> new HelpException(HELP_NOT_FOUND)); + if (!bs.isMyPost(userId)) throw new IllegalStateException("unauthorized"); + bs.update(filter(req.content()), req.contactWay()); + return BusinessSupportResultResponse.response(MODIFY_SUCCESS, bs.getId()); + } + + @Transactional + public BusinessSupportResultResponse delete(Long id, Long userId) { + BusinessSupport bs = repository.findById(id).orElseThrow(); + if (!bs.isMyPost(userId)) throw new IllegalStateException("unauthorized"); + bs.delete(); + return BusinessSupportResultResponse.response(DELETE_SUCCESS, bs.getId()); + } + + @Transactional(readOnly = true) + public PageResponse getList(BusinessSupportPageableRequest req, Long userId) { + List list = repository.findAllByUserId(userId); + List infos = list.stream() + .map(b -> BusinessSupportListResponse.BusinessInfo.of(b.getId(), b.getContent(), b.getCreateAt(), b.getStatus())) + .toList(); + return PageResponse.of(BusinessSupportListResponse.of((long) infos.size(), infos), CursorPageable.of(list, req.pageSize(), req.cursor())); + } + + @Transactional(readOnly = true) + public BusinessSupportDetailItem getDetail(Long id, Long userId) { + BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(); + return BusinessSupportDetailItem.builder() + .id(bs.getId()) + .content(bs.getContent()) + .contactWay(bs.getContactWay()) + .createAt(bs.getCreateAt()) + .status(bs.getStatus()) + .adminId(bs.getAdminId()) + .responseContent(bs.getResponseContent()) + .lastModifyAt(bs.getLastModifyAt()) + .build(); + } +} From aa24b84dd0242fc7d796b5973ef47d3c1d21eeb3 Mon Sep 17 00:00:00 2001 From: chadongmin Date: Tue, 24 Jun 2025 21:00:21 +0900 Subject: [PATCH 3/7] =?UTF-8?q?test=20:=20=EB=8B=A8=EC=9C=84=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BusinessSupportServiceTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java diff --git a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java new file mode 100644 index 000000000..074633cd3 --- /dev/null +++ b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java @@ -0,0 +1,51 @@ +package app.bottlenote.support.business.service; + +import app.bottlenote.common.profanity.ProfanityClient; +import app.bottlenote.support.business.domain.BusinessSupport; +import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; +import app.bottlenote.support.business.repository.BusinessSupportRepository; +import app.bottlenote.user.facade.UserFacade; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +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 java.util.Optional; + +import static app.bottlenote.support.business.constant.BusinessResultMessage.REGISTER_SUCCESS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class BusinessSupportServiceTest { + + @InjectMocks + private BusinessSupportService service; + @Mock + private BusinessSupportRepository repository; + @Mock + private UserFacade userFacade; + @Mock + private ProfanityClient profanityClient; + + @Test + @DisplayName("비지니스 문의 등록") + void register() { + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("hi", null); + when(profanityClient.getFilteredText(anyString())).thenReturn("hi"); + when(repository.findTopByUserIdAndContentOrderByIdDesc(anyLong(), anyString())) + .thenReturn(Optional.empty()); + when(repository.save(any())).thenReturn(BusinessSupport.create(1L, "hi", null)); + + BusinessSupportResultResponse res = service.register(req, 1L); + assertEquals(REGISTER_SUCCESS, res.codeMessage()); + } +} From 97a1db26080846ad67259ce6fc47c02e26684ff9 Mon Sep 17 00:00:00 2001 From: chadongmin Date: Tue, 24 Jun 2025 21:07:31 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test=20:=20=ED=86=B5=ED=95=A9=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/constant/ContactType.java | 8 + .../business/domain/BusinessSupport.java | 10 +- .../request/BusinessSupportUpsertRequest.java | 3 +- .../service/BusinessSupportService.java | 4 +- .../fixture/BusinessSupportTestFactory.java | 25 +++ .../BusinessSupportIntegrationTest.java | 145 ++++++++++++++++++ .../service/BusinessSupportServiceTest.java | 139 +++++++++++++++++ 7 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 src/main/java/app/bottlenote/support/business/constant/ContactType.java create mode 100644 src/test/java/app/bottlenote/support/business/fixture/BusinessSupportTestFactory.java create mode 100644 src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java diff --git a/src/main/java/app/bottlenote/support/business/constant/ContactType.java b/src/main/java/app/bottlenote/support/business/constant/ContactType.java new file mode 100644 index 000000000..ad274cb91 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/constant/ContactType.java @@ -0,0 +1,8 @@ +package app.bottlenote.support.business.constant; + +import lombok.Getter; + +@Getter +public enum ContactType { + EMAIL, PHONE, ETC; +} diff --git a/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java index 7bb7d3e26..add63675a 100644 --- a/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java +++ b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java @@ -1,6 +1,7 @@ package app.bottlenote.support.business.domain; import app.bottlenote.common.domain.BaseEntity; +import app.bottlenote.support.business.constant.ContactType; import app.bottlenote.support.constant.StatusType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -36,7 +37,8 @@ public class BusinessSupport extends BaseEntity { @Comment("연락 방식") @Column(name = "contact_way") - private String contactWay; + @Enumerated(EnumType.STRING) + private ContactType contactWay; @Enumerated(EnumType.STRING) @Column(name = "status") @@ -49,7 +51,7 @@ public class BusinessSupport extends BaseEntity { private String responseContent; @Builder - private BusinessSupport(Long id, Long userId, String content, String contactWay, Long adminId, String responseContent) { + private BusinessSupport(Long id, Long userId, String content, ContactType contactWay, Long adminId, String responseContent) { this.id = id; this.userId = userId; this.content = content; @@ -58,7 +60,7 @@ private BusinessSupport(Long id, Long userId, String content, String contactWay, this.responseContent = responseContent; } - public static BusinessSupport create(Long userId, String content, String contactWay) { + public static BusinessSupport create(Long userId, String content, ContactType contactWay) { return BusinessSupport.builder() .userId(userId) .content(content) @@ -66,7 +68,7 @@ public static BusinessSupport create(Long userId, String content, String contact .build(); } - public void update(String content, String contactWay) { + public void update(String content, ContactType contactWay) { this.content = content; this.contactWay = contactWay; } diff --git a/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java index 132c1ac25..fe3bb20ec 100644 --- a/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java +++ b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java @@ -1,5 +1,6 @@ package app.bottlenote.support.business.dto.request; +import app.bottlenote.support.business.constant.ContactType; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -8,7 +9,7 @@ public record BusinessSupportUpsertRequest( @Size(max = 500) String content, - String contactWay + ContactType contactWay ) { } diff --git a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java index 1c202dbe5..89a116787 100644 --- a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java +++ b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -79,11 +79,11 @@ public PageResponse getList(BusinessSupportPageable @Transactional(readOnly = true) public BusinessSupportDetailItem getDetail(Long id, Long userId) { - BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(); + BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(() -> new HelpException(HELP_NOT_FOUND)); return BusinessSupportDetailItem.builder() .id(bs.getId()) .content(bs.getContent()) - .contactWay(bs.getContactWay()) + .contactWay(bs.getContactWay().name()) .createAt(bs.getCreateAt()) .status(bs.getStatus()) .adminId(bs.getAdminId()) diff --git a/src/test/java/app/bottlenote/support/business/fixture/BusinessSupportTestFactory.java b/src/test/java/app/bottlenote/support/business/fixture/BusinessSupportTestFactory.java new file mode 100644 index 000000000..f040b9a00 --- /dev/null +++ b/src/test/java/app/bottlenote/support/business/fixture/BusinessSupportTestFactory.java @@ -0,0 +1,25 @@ +package app.bottlenote.support.business.fixture; + +import app.bottlenote.support.business.constant.ContactType; +import app.bottlenote.support.business.domain.BusinessSupport; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class BusinessSupportTestFactory { + + @Autowired + private EntityManager em; + + @Transactional + public BusinessSupport persist(Long userId) { + BusinessSupport bs = BusinessSupport.create(userId, "문의", ContactType.EMAIL); + em.persist(bs); + em.flush(); + return bs; + } +} diff --git a/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java b/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java new file mode 100644 index 000000000..2167c8f09 --- /dev/null +++ b/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java @@ -0,0 +1,145 @@ +package app.bottlenote.support.business.integration; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.support.business.constant.ContactType; +import app.bottlenote.support.business.domain.BusinessSupport; +import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.fixture.BusinessSupportTestFactory; +import app.bottlenote.support.business.repository.BusinessSupportRepository; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.fixture.UserTestFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Tag("integration") +@DisplayName("[integration] BusinessSupportController") +class BusinessSupportIntegrationTest extends IntegrationTestSupport { + + @Autowired + private UserTestFactory userFactory; + @Autowired + private BusinessSupportTestFactory businessFactory; + @Autowired + private BusinessSupportRepository repository; + + @Test + @DisplayName("비지니스 문의를 등록할 수 있다.") + void register() throws Exception { + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("문의", ContactType.EMAIL); + + mockMvc.perform(post("/api/v1/business-support") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(req)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + + + assertEquals(1, repository.findAll().size()); + } + + + @Test + @DisplayName("인증되지 않은 사용자는 문의를 등록할 수 없다.") + void register_fail_unauthorized() throws Exception { + // given + BusinessSupportUpsertRequest request = new BusinessSupportUpsertRequest("문의", ContactType.EMAIL); + + // when & then + mockMvc.perform(post("/api/v1/business-support") + .with(csrf()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("자신이 등록한 문의 목록을 조회할 수 있다. (200 OK)") + void get_list_success() throws Exception { + // given + User user = userFactory.persistUser(); + businessFactory.persist(user.getId()); + + // when & then + mockMvc.perform(get("/api/v1/business-support") + .header("Authorization", "Bearer " + getToken())) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("자신이 등록한 문의 상세 내용을 조회할 수 있다. (200 OK)") + void get_detail_success() throws Exception { + // given + User user = userFactory.persistUser(); + BusinessSupport support = businessFactory.persist(user.getId()); + + // when & then + mockMvc.perform(get("/api/v1/business-support/{id}", support.getId()) + .header("Authorization", "Bearer " + getToken())) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("존재하지 않는 문의는 상세 조회할 수 없다. (404 Not Found)") + void get_detail_fail_not_found() throws Exception { + // given + long nonExistId = 999L; + + // when & then + mockMvc.perform(get("/api/v1/business-support/{id}", nonExistId) + .header("Authorization", "Bearer " + getToken())) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("자신의 문의를 성공적으로 수정할 수 있다. (200 OK)") + void modify_success() throws Exception { + // given + User user = userFactory.persistUser(); + BusinessSupport support = businessFactory.persist(user.getId()); + BusinessSupportUpsertRequest request = new BusinessSupportUpsertRequest("수정된 내용입니다.", ContactType.PHONE); + + // when & then + mockMvc.perform(patch("/api/v1/business-support/{id}", support.getId()) + .with(csrf()) + .header("Authorization", "Bearer " + getToken()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("자신의 문의를 성공적으로 삭제할 수 있다. (200 OK)") + void delete_success() throws Exception { + // given + User user = userFactory.persistUser(); + BusinessSupport support = businessFactory.persist(user.getId()); + + // when & then + mockMvc.perform(delete("/api/v1/business-support/{id}", support.getId()) + .with(csrf()) + .header("Authorization", "Bearer " + getToken())) + .andDo(print()) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java index 074633cd3..4dadbf618 100644 --- a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java +++ b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java @@ -1,6 +1,7 @@ package app.bottlenote.support.business.service; import app.bottlenote.common.profanity.ProfanityClient; +import app.bottlenote.support.business.constant.ContactType; import app.bottlenote.support.business.domain.BusinessSupport; import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; @@ -10,17 +11,26 @@ import org.junit.jupiter.api.Tag; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; +import static app.bottlenote.support.business.constant.BusinessResultMessage.DELETE_SUCCESS; +import static app.bottlenote.support.business.constant.BusinessResultMessage.MODIFY_SUCCESS; import static app.bottlenote.support.business.constant.BusinessResultMessage.REGISTER_SUCCESS; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @Tag("unit") @@ -36,6 +46,10 @@ class BusinessSupportServiceTest { @Mock private ProfanityClient profanityClient; + // BusinessSupport 객체를 Spy로 만들어 delete 메소드 호출을 감시 + @Spy + private BusinessSupport businessSupport; + @Test @DisplayName("비지니스 문의 등록") void register() { @@ -48,4 +62,129 @@ void register() { BusinessSupportResultResponse res = service.register(req, 1L); assertEquals(REGISTER_SUCCESS, res.codeMessage()); } + + @Test + @DisplayName("동일한 내용의 문의를 중복 등록하면 예외가 발생한다.") + void register_fail_with_duplicate_request() { + // given + Long userId = 1L; + String content = "중복 문의 내용"; + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest(content, ContactType.EMAIL); + BusinessSupport existingSupport = BusinessSupport.create(userId, content, ContactType.EMAIL); + + // when + doNothing().when(userFacade).isValidUserId(userId); + when(profanityClient.getFilteredText(content)).thenReturn(content); + when(repository.findTopByUserIdAndContentOrderByIdDesc(userId, content)).thenReturn(Optional.of(existingSupport)); + + // then + assertThrows(IllegalStateException.class, () -> service.register(req, userId)); + verify(repository, never()).save(any(BusinessSupport.class)); + } + + @Test + @DisplayName("부적절한 단어가 포함된 내용은 필터링하여 등록한다.") + void register_with_profanity_content() { + // given + Long userId = 1L; + String profanityContent = "나쁜말 포함"; + String filteredContent = "필터링된_텍스트"; + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest(profanityContent, ContactType.EMAIL); + ArgumentCaptor captor = ArgumentCaptor.forClass(BusinessSupport.class); + + BusinessSupport mockedSavedEntity = BusinessSupport.builder() + .id(123L) + .userId(userId) + .content(filteredContent) + .contactWay(req.contactWay()) + .build(); + + // when + when(profanityClient.getFilteredText(profanityContent)).thenReturn(filteredContent); + when(repository.save(captor.capture())).thenReturn(mockedSavedEntity); + + // then + service.register(req, userId); + + // then + // 1. profanityClient가 정확한 인자와 함께 호출되었는지 검증 + verify(profanityClient, times(1)).getFilteredText(profanityContent); + // 2. repository.save가 필터링된 내용으로 호출되었는지 검증 + assertEquals(filteredContent, captor.getValue().getContent()); + } + + @Test + @DisplayName("본인의 문의를 성공적으로 수정할 수 있다.") + void modify_success() { + // given + Long supportId = 1L; + Long userId = 1L; + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("수정된 내용", ContactType.EMAIL); + BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(userId).content("원본 내용").build(); + + // when + when(repository.findById(supportId)).thenReturn(Optional.of(origin)); + when(profanityClient.getFilteredText(req.content())).thenReturn(req.content()); + + // then + BusinessSupportResultResponse res = service.modify(supportId, req, userId); + assertEquals(MODIFY_SUCCESS, res.codeMessage()); + assertEquals(supportId, res.id()); + assertEquals("수정된 내용", origin.getContent()); // 원본 객체의 내용이 변경되었는지 확인 + assertEquals("new@example.com", origin.getContactWay()); + } + + @Test + @DisplayName("다른 사람의 문의를 수정하려고 하면 예외가 발생한다.") + void modify_fail_with_unauthorized_user() { + // given + Long supportId = 1L; + Long ownerId = 1L; // 문의 소유자 + Long requesterId = 2L; // 수정을 시도하는 사용자 + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("수정 시도", ContactType.EMAIL); + BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(ownerId).build(); + + // when + when(repository.findById(supportId)).thenReturn(Optional.of(origin)); + + // then + assertThrows(IllegalStateException.class, () -> service.modify(supportId, req, requesterId)); + } + + + @Test + @DisplayName("본인의 문의를 성공적으로 삭제(Soft Delete)할 수 있다.") + void delete_success_soft_delete() { + // given + Long supportId = 1L; + Long userId = 1L; + // BusinessSupport 엔티티의 실제 인스턴스를 생성하고, 이를 Spy 객체로 감싸서 실제 메소드를 호출하면서도 호출 여부를 추적 + BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(userId).build(); + BusinessSupport spySupport = org.mockito.Mockito.spy(origin); + + // when + when(repository.findById(supportId)).thenReturn(Optional.of(spySupport)); + + // then + BusinessSupportResultResponse res = service.delete(supportId, userId); + assertEquals(DELETE_SUCCESS, res.codeMessage()); + assertEquals(supportId, res.id()); + verify(spySupport, times(1)).delete(); // spy 객체의 delete 메소드가 호출되었는지 검증 + } + + @Test + @DisplayName("다른 사람의 문의를 삭제하려고 하면 예외가 발생한다.") + void delete_fail_with_unauthorized_user() { + // given + Long supportId = 1L; + Long ownerId = 1L; + Long requesterId = 2L; + BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(ownerId).build(); + + // when + when(repository.findById(supportId)).thenReturn(Optional.of(origin)); + + // then + assertThrows(IllegalStateException.class, () -> service.delete(supportId, requesterId)); + } } From c6b48129f5b6a46cf71b9b0aa49f6f0be7cedc4d Mon Sep 17 00:00:00 2001 From: chadongmin Date: Thu, 26 Jun 2025 20:30:06 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test=20:=20restdocs=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/support/business/business-delete.adoc | 21 ++ .../api/support/business/business-modify.adoc | 26 ++ .../business/business-read-detail.adoc | 25 ++ .../support/business/business-read-list.adoc | 18 ++ .../support/business/business-register.adoc | 20 ++ src/docs/asciidoc/index.adoc | 10 + .../business/domain/BusinessSupport.java | 3 + .../service/BusinessSupportService.java | 2 +- .../service/BusinessSupportServiceTest.java | 1 - ...RestDocsBusinessSupportControllerTest.java | 293 ++++++++++++++++++ 10 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 src/docs/asciidoc/api/support/business/business-delete.adoc create mode 100644 src/docs/asciidoc/api/support/business/business-modify.adoc create mode 100644 src/docs/asciidoc/api/support/business/business-read-detail.adoc create mode 100644 src/docs/asciidoc/api/support/business/business-read-list.adoc create mode 100644 src/docs/asciidoc/api/support/business/business-register.adoc create mode 100644 src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java diff --git a/src/docs/asciidoc/api/support/business/business-delete.adoc b/src/docs/asciidoc/api/support/business/business-delete.adoc new file mode 100644 index 000000000..0d4327377 --- /dev/null +++ b/src/docs/asciidoc/api/support/business/business-delete.adoc @@ -0,0 +1,21 @@ +=== 비지니스 문의 삭제 === + +용자는 등록한 문의글을 삭제할 수 있습니다. + +물리적으로 삭제되는 것이 아닌 논리 삭제가 수행되며 status 컬럼이 DELETED로 변경됩니. + +PathVariable로 문의글의 id를 전달합니다. + +존재하지 않는 문의글 id로 요청을 보낸 경우 HELP_NOT_FOUND 예외가 발생합니다. + +로그인 한 유저만 요청이 가능하며, 문의글의 작성자가 아닌 경우 HELP_NOT_AUTHORIZED 예외가 발생합니다. + +[source] +---- +DELETE /api/v1/business-support/{id} +---- + +[discrete] +==== 응답 파라미터 ==== +include::{snippets}/support/business/delete/response-fields.adoc[] +include::{snippets}/support/business/delete/response-body.adoc[] diff --git a/src/docs/asciidoc/api/support/business/business-modify.adoc b/src/docs/asciidoc/api/support/business/business-modify.adoc new file mode 100644 index 000000000..592eaf72e --- /dev/null +++ b/src/docs/asciidoc/api/support/business/business-modify.adoc @@ -0,0 +1,26 @@ +=== 비지니스 문의 수정 === + +사용자는 등록한 비지니스 문의글을 수정할 수 있습니다. + +수정하고자 하는 문의글 `제목`, `내용`, `타입` 은 null일 수 없습니다 + +PathVariable로 문의글의 id를 전달합니다. + +존재하지 않는 문의글 id로 요청을 보낸 경우 HELP_NOT_FOUND 예외가 발생합니다. + +로그인 한 유저만 요청이 가능하며, 문의글의 작성자가 아닌 경우 HELP_NOT_AUTHORIZED 예외가 발생합니다. + +[source] +---- +PATCH /api/v1/business-support/{id} +---- + +[discrete] +==== 요청 파라미터 ==== +include::{snippets}/support/business/modify/request-fields.adoc[] +include::{snippets}/support/business/modify/request-body.adoc[] + +[discrete] +==== 응답 파라미터 ==== +include::{snippets}/support/business/modify/response-fields.adoc[] +include::{snippets}/support/business/modify/response-body.adoc[] diff --git a/src/docs/asciidoc/api/support/business/business-read-detail.adoc b/src/docs/asciidoc/api/support/business/business-read-detail.adoc new file mode 100644 index 000000000..0e251ac25 --- /dev/null +++ b/src/docs/asciidoc/api/support/business/business-read-detail.adoc @@ -0,0 +1,25 @@ +=== 비지니스 문의 상세 조회 === + +사용자는 등록한 비지니스 문의글 상세조회 할 수 있습니다. + +PathVariable로 문의글의 id를 전달합니다. + +로그인 한 유저만 조회가 가능합니다. + +존재하지 않는 문의글 id로 요청을 보낸 경우 HELP_NOT_FOUND 예외가 발생합니다. + +[source] +---- +GET /api/v1/business-support/{id} +---- + +[discrete] +==== 요청 파라미터 ==== + +- PathVariable id : 문의 아이디 + +[discrete] +==== 응답 파라미터 ==== + +include::{snippets}/support/business/read-detail/response-fields.adoc[] +include::{snippets}/support/business/read-detail/response-body.adoc[] diff --git a/src/docs/asciidoc/api/support/business/business-read-list.adoc b/src/docs/asciidoc/api/support/business/business-read-list.adoc new file mode 100644 index 000000000..f334a6810 --- /dev/null +++ b/src/docs/asciidoc/api/support/business/business-read-list.adoc @@ -0,0 +1,18 @@ +=== 비지니스 문의 목록 조회 === + +사용자는 등록한 비지니스 문의글의 목록을 조회할 수 있습니다. + +로그인 한 유저만 조회가 가능합니다. + +QueryParam으로 cursor와 pageSize를 전달하며, default 값은 cursor는 0, pageSize는 10입니다. + +[source] +---- +GET /api/v1/business-support +---- + +[discrete] +==== 응답 파라미터 ==== + +include::{snippets}/support/business/read-list/response-fields.adoc[] +include::{snippets}/support/business/read-list/response-body.adoc[] diff --git a/src/docs/asciidoc/api/support/business/business-register.adoc b/src/docs/asciidoc/api/support/business/business-register.adoc new file mode 100644 index 000000000..61358a08f --- /dev/null +++ b/src/docs/asciidoc/api/support/business/business-register.adoc @@ -0,0 +1,20 @@ +=== 비지니스 문의 등록 === + +사용자는 비지니스 관련 문의를 등록할 수 있습니다. + +[source] +---- +POST /api/v1/business-support +---- + +[discrete] +==== 요청 파라미터 ==== + +include::{snippets}/support/business/register/request-fields.adoc[] +include::{snippets}/support/business/register/request-body.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +include::{snippets}/support/business/register/response-fields.adoc[] +include::{snippets}/support/business/register/response-body.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index b206b06c9..e01238849 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -188,6 +188,16 @@ include::api/support/help/help-modify.adoc[] include::api/support/help/help-delete.adoc[] +include::api/support/business/business-register.adoc[] + +include::api/support/business/business-read-list.adoc[] + +include::api/support/business/business-read-detail.adoc[] + +include::api/support/business/business-modify.adoc[] + +include::api/support/business/business-delete.adoc[] + == 팔로우 (follow) 관련 API include::api/follow/follow-update.adoc[] diff --git a/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java index add63675a..a508b2eaf 100644 --- a/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java +++ b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java @@ -42,12 +42,15 @@ public class BusinessSupport extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "status") + @Comment("문의 타입") private StatusType status = StatusType.WAITING; @Column(name = "admin_id") + @Comment("어드민 ID") private Long adminId; @Column(name = "response_content") + @Comment("문의 답변 내용") private String responseContent; @Builder diff --git a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java index 89a116787..cf05c77ee 100644 --- a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java +++ b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -74,7 +74,7 @@ public PageResponse getList(BusinessSupportPageable List infos = list.stream() .map(b -> BusinessSupportListResponse.BusinessInfo.of(b.getId(), b.getContent(), b.getCreateAt(), b.getStatus())) .toList(); - return PageResponse.of(BusinessSupportListResponse.of((long) infos.size(), infos), CursorPageable.of(list, req.pageSize(), req.cursor())); + return PageResponse.of(BusinessSupportListResponse.of((long) infos.size(), infos), CursorPageable.of(infos, req.pageSize(), req.cursor())); } @Transactional(readOnly = true) diff --git a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java index 4dadbf618..83621d19b 100644 --- a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java +++ b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java @@ -131,7 +131,6 @@ void modify_success() { assertEquals(MODIFY_SUCCESS, res.codeMessage()); assertEquals(supportId, res.id()); assertEquals("수정된 내용", origin.getContent()); // 원본 객체의 내용이 변경되었는지 확인 - assertEquals("new@example.com", origin.getContactWay()); } @Test diff --git a/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java b/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java new file mode 100644 index 000000000..b6dce84c6 --- /dev/null +++ b/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java @@ -0,0 +1,293 @@ +package app.docs.support.business; + +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.global.service.cursor.CursorPageable; +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.support.business.constant.ContactType; +import app.bottlenote.support.business.controller.BusinessSupportController; +import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; +import app.bottlenote.support.business.dto.response.BusinessSupportListResponse; +import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; +import app.bottlenote.support.business.service.BusinessSupportService; +import app.bottlenote.support.constant.StatusType; +import app.docs.AbstractRestDocs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static app.bottlenote.support.business.constant.BusinessResultMessage.DELETE_SUCCESS; +import static app.bottlenote.support.business.constant.BusinessResultMessage.MODIFY_SUCCESS; +import static app.bottlenote.support.business.constant.BusinessResultMessage.REGISTER_SUCCESS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@Tag("restdocs") +@DisplayName("[restdocs] BusinessSupportController") +class RestDocsBusinessSupportControllerTest extends AbstractRestDocs { + + private BusinessSupportService businessSupportService = mock(BusinessSupportService.class); + + private MockedStatic mockedSecurityUtil; + + @Override + protected Object initController() { + return new BusinessSupportController(businessSupportService); + } + + @BeforeEach + void setup() { + mockedSecurityUtil = mockStatic(SecurityContextUtil.class); + } + + @AfterEach + void tearDown() { + mockedSecurityUtil.close(); + } + + + @Test + @DisplayName("비즈니스 문의를 등록할 수 있다.") + void register_success() throws Exception { + // given + Long userId = 1L; + BusinessSupportUpsertRequest request = new BusinessSupportUpsertRequest("새로운 비즈니스 문의입니다.", ContactType.EMAIL); + BusinessSupportResultResponse response = BusinessSupportResultResponse.response(REGISTER_SUCCESS, 1L); + + // when + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + when(businessSupportService.register(any(), anyLong())).thenReturn(response); + + // then + mockMvc.perform(post("/api/v1/business-support") + .with(csrf()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("support/business/register", + requestFields( + fieldWithPath("content").type(JsonFieldType.STRING).description("문의 내용"), + fieldWithPath("contactWay").type(JsonFieldType.STRING).description("연락 방식 (이메일, 전화번호 등)").optional() + ), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("data.codeMessage").description("성공 메시지 코드"), + fieldWithPath("data.message").description("처리 결과 메시지"), + fieldWithPath("data.id").description("등록된 문의 ID"), + fieldWithPath("data.responseAt").description("서버 응답 일시"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored() + ) + )); + } + + @Test + @DisplayName("비즈니스 문의 목록을 조회할 수 있다.") + void get_list_success() throws Exception { + // given + Long userId = 1L; + List infos = List.of( + new BusinessSupportListResponse.BusinessInfo(2L, "두번째 문의", LocalDateTime.now(), StatusType.WAITING), + new BusinessSupportListResponse.BusinessInfo(1L, "첫번째 문의", LocalDateTime.now().minusDays(1), StatusType.SUCCESS) + ); + BusinessSupportListResponse listResponse = BusinessSupportListResponse.of((long) infos.size(), infos); + PageResponse pageResponse = PageResponse.of(listResponse, CursorPageable.of(infos, 10L, 10L)); + + // when + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + when(businessSupportService.getList(any(), anyLong())).thenReturn(pageResponse); + + // then + mockMvc.perform(get("/api/v1/business-support") + .param("pageSize", "10") + .param("cursor", "0") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("support/business/read-list", + queryParameters( + parameterWithName("pageSize").description("페이지 당 항목 수").optional(), + parameterWithName("cursor").description("다음 페이지를 위한 커서 값").optional() + ), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("data.totalCount").description("총 문의 개수"), + fieldWithPath("data.list[].id").description("문의 ID"), + fieldWithPath("data.list[].content").description("문의 내용"), + fieldWithPath("data.list[].createAt").description("문의 생성일"), + fieldWithPath("data.list[].status").description("문의 상태 (WAITING, ANSWERED 등)"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored(), + fieldWithPath("meta.pageable.currentCursor").description("현재 커서 위치"), + fieldWithPath("meta.pageable.cursor").description("다음 페이지 조회를 위한 커서 정보"), + fieldWithPath("meta.pageable.pageSize").description("페이지 크기"), + fieldWithPath("meta.pageable.hasNext").description("다음 페이지 존재 여부") + ) + )); + } + + @Test + @DisplayName("비즈니스 문의를 상세 조회할 수 있다.") + void get_detail_success() throws Exception { + // given + Long userId = 1L; + Long supportId = 1L; + BusinessSupportDetailItem response = BusinessSupportDetailItem.builder() + .id(supportId) + .content("문의 상세 내용입니다.") + .contactWay("test@example.com") + .createAt(LocalDateTime.now().minusDays(1)) + .status(StatusType.SUCCESS) + .adminId(100L) + .responseContent("답변 드립니다.") + .lastModifyAt(LocalDateTime.now()) + .build(); + + // when + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + when(businessSupportService.getDetail(anyLong(), anyLong())).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/business-support/{id}", supportId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("support/business/read-detail", + pathParameters( + parameterWithName("id").description("문의 아이디") + ), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("data.id").description("문의 ID"), + fieldWithPath("data.content").description("문의 내용"), + fieldWithPath("data.contactWay").description("연락 방식"), + fieldWithPath("data.createAt").description("문의 생성일"), + fieldWithPath("data.status").description("문의 상태"), + fieldWithPath("data.adminId").description("답변 관리자 ID").optional(), + fieldWithPath("data.responseContent").description("관리자 답변 내용").optional(), + fieldWithPath("data.lastModifyAt").description("최종 수정일"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored() + ) + )); + } + + + @Test + @DisplayName("비즈니스 문의를 수정할 수 있다.") + void modify_success() throws Exception { + // given + Long userId = 1L; + Long supportId = 1L; + BusinessSupportUpsertRequest request = new BusinessSupportUpsertRequest("수정된 내용입니다.", ContactType.EMAIL); + BusinessSupportResultResponse response = BusinessSupportResultResponse.response(MODIFY_SUCCESS, supportId); + + // when + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + when(businessSupportService.modify(anyLong(), any(), anyLong())).thenReturn(response); + + // then + mockMvc.perform(patch("/api/v1/business-support/{id}", supportId) + .with(csrf()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("support/business/modify", + pathParameters( + parameterWithName("id").description("수정할 문의 ID") + ), + requestFields( + fieldWithPath("content").description("수정할 내용"), + fieldWithPath("contactWay").description("수정할 연락 방식").optional() + ), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("data.codeMessage").description("성공 메시지 코드"), + fieldWithPath("data.message").description("처리 결과 메시지"), + fieldWithPath("data.id").description("수정된 문의 ID"), + fieldWithPath("data.responseAt").description("서버 응답 일시"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored() + ) + )); + } + + + @Test + @DisplayName("비즈니스 문의를 삭제할 수 있다.") + void delete_success() throws Exception { + // given + Long userId = 1L; + Long supportId = 1L; + BusinessSupportResultResponse response = BusinessSupportResultResponse.response(DELETE_SUCCESS, supportId); + + // when + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + when(businessSupportService.delete(anyLong(), anyLong())).thenReturn(response); + + // then + mockMvc.perform(delete("/api/v1/business-support/{id}", supportId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("support/business/delete", + pathParameters( + parameterWithName("id").description("삭제할 문의 ID") + ), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("data.codeMessage").description("성공 메시지 코드"), + fieldWithPath("data.message").description("처리 결과 메시지"), + fieldWithPath("data.id").description("삭제된 문의 ID"), + fieldWithPath("data.responseAt").description("서버 응답 일시"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored() + ) + )); + } +} From 9450af0930dcaaf6dc937e893bc04f8f6fe06bed Mon Sep 17 00:00:00 2001 From: chadongmin Date: Thu, 26 Jun 2025 22:22:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix=20:=20CursorPageable.of()=EA=B0=80=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=98=EB=8A=94=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EB=B6=80=EB=B6=84=EC=9D=98=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=A6=AC=EC=8A=A4=ED=8A=B8(sublist)=EB=A5=BC=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EC=83=9D=EC=84=B1=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 변경 불가능한 리스트를 직접 수정해서 발생하던 UnsupportedOperationException을 해결 --- .../bottlenote/global/service/cursor/CursorPageable.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/app/bottlenote/global/service/cursor/CursorPageable.java b/src/main/java/app/bottlenote/global/service/cursor/CursorPageable.java index ea1727abb..4d735d155 100644 --- a/src/main/java/app/bottlenote/global/service/cursor/CursorPageable.java +++ b/src/main/java/app/bottlenote/global/service/cursor/CursorPageable.java @@ -43,10 +43,10 @@ public CursorPageable(Long currentCursor, Long cursor, Long pageSize, Boolean ha public static CursorPageable of(List items, Long currentCursor, Long pageSize) { boolean hasNext = items.size() > pageSize; - // 다음 페이지가 있는 경우 추가 항목 제거 - if (hasNext) { - items.remove(items.size() - 1); - } + // 결과 리스트 생성 (원본 리스트를 수정하지 않음) + List result = hasNext + ? items.subList(0, items.size() - 1) + : items; return CursorPageable.builder() .currentCursor(currentCursor) From dfa9531306cebfdea5490f32b68ef639864ad32d Mon Sep 17 00:00:00 2001 From: chadongmin Date: Sun, 29 Jun 2025 22:26:48 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MockTest -> FakeRepositoryTest로 수정 - CollectionResponse객체 적용 - 적절한 예외를 던지도록 수정 --- .../profanity/DefaultProfanityClient.java | 8 + .../common/profanity/ProfanityClient.java | 8 + .../controller/BusinessSupportController.java | 14 +- .../dto/response/BusinessInfoResponse.java | 18 ++ .../response/BusinessSupportListResponse.java | 20 -- .../exception/BusinessSupportException.java | 13 + .../BusinessSupportExceptionCode.java | 29 ++ .../repository/BusinessSupportRepository.java | 9 +- .../JpaBusinessSupportRepository.java | 8 + .../service/BusinessSupportService.java | 42 ++- .../common/profanity/FakeProfanityClient.java | 71 +++++ .../review/fixture/FakeProfanityClient.java | 8 + .../InMemoryBusinessSupportRepository.java | 63 +++++ .../service/BusinessSupportServiceTest.java | 254 ++++++++++++------ ...RestDocsBusinessSupportControllerTest.java | 37 ++- 15 files changed, 438 insertions(+), 164 deletions(-) create mode 100644 src/main/java/app/bottlenote/support/business/dto/response/BusinessInfoResponse.java delete mode 100644 src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java create mode 100644 src/main/java/app/bottlenote/support/business/exception/BusinessSupportException.java create mode 100644 src/main/java/app/bottlenote/support/business/exception/BusinessSupportExceptionCode.java create mode 100644 src/main/java/app/bottlenote/support/business/repository/JpaBusinessSupportRepository.java create mode 100644 src/test/java/app/bottlenote/common/profanity/FakeProfanityClient.java create mode 100644 src/test/java/app/bottlenote/support/business/fixture/InMemoryBusinessSupportRepository.java diff --git a/src/main/java/app/bottlenote/common/profanity/DefaultProfanityClient.java b/src/main/java/app/bottlenote/common/profanity/DefaultProfanityClient.java index 7272273b6..6a0004e90 100644 --- a/src/main/java/app/bottlenote/common/profanity/DefaultProfanityClient.java +++ b/src/main/java/app/bottlenote/common/profanity/DefaultProfanityClient.java @@ -45,6 +45,14 @@ public String getFilteredText(String text) { return response.filtered(); } + @Override + public String filter(String content) { + if (content == null || content.isBlank()) { + return ""; + } + return getFilteredText(content); + } + @Override public void validateProfanity(String text) { log.info("[validateProfanity] 검증 요청 대상: {}", text); diff --git a/src/main/java/app/bottlenote/common/profanity/ProfanityClient.java b/src/main/java/app/bottlenote/common/profanity/ProfanityClient.java index 345d9ca84..d0a789ed5 100644 --- a/src/main/java/app/bottlenote/common/profanity/ProfanityClient.java +++ b/src/main/java/app/bottlenote/common/profanity/ProfanityClient.java @@ -23,6 +23,14 @@ public interface ProfanityClient { */ String getFilteredText(String text); + /** + * 텍스트가 null이거나 비어있는지 확인하고, 필터링 된 텍스트를 반환한다. + * + * @param content 필터링 대상 텍스트 + * @return 필터링 된 텍스트, 텍스트가 null이거나 비어있으면 빈 문자열 반환 + */ + String filter(String content); + /** * 욕설이 포함되어 있는지 확인한다. * 만약 욕설이 포함되어 있다면, 예외를 발생시킨다. diff --git a/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java index 8787542f7..9d80ce16f 100644 --- a/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java +++ b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java @@ -1,15 +1,13 @@ package app.bottlenote.support.business.controller; +import app.bottlenote.global.data.response.CollectionResponse; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.global.service.cursor.PageResponse; -import app.bottlenote.global.service.meta.MetaService; import app.bottlenote.support.business.dto.request.BusinessSupportPageableRequest; import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessInfoResponse; import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; -import app.bottlenote.support.business.dto.response.BusinessSupportListResponse; import app.bottlenote.support.business.service.BusinessSupportService; -import app.bottlenote.support.help.exception.HelpException; import app.bottlenote.user.exception.UserException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -41,14 +39,14 @@ public ResponseEntity register(@Valid @RequestBody BusinessSupportUpsertReque @GetMapping public ResponseEntity getAllList(@ModelAttribute BusinessSupportPageableRequest req) { - Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new HelpException(REQUIRED_USER_ID)); - PageResponse page = service.getList(req, userId); - return GlobalResponse.ok(page.content(), MetaService.createMetaInfo().add("pageable", page.cursorPageable())); + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new UserException(REQUIRED_USER_ID)); + CollectionResponse collection = service.getList(req, userId); + return GlobalResponse.ok(collection); } @GetMapping("/{id}") public ResponseEntity getDetail(@PathVariable Long id) { - Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new HelpException(REQUIRED_USER_ID)); + Long userId = SecurityContextUtil.getUserIdByContext().orElseThrow(() -> new UserException(REQUIRED_USER_ID)); BusinessSupportDetailItem item = service.getDetail(id, userId); return GlobalResponse.ok(item); } diff --git a/src/main/java/app/bottlenote/support/business/dto/response/BusinessInfoResponse.java b/src/main/java/app/bottlenote/support/business/dto/response/BusinessInfoResponse.java new file mode 100644 index 000000000..c13d37df6 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/response/BusinessInfoResponse.java @@ -0,0 +1,18 @@ +package app.bottlenote.support.business.dto.response; + +import app.bottlenote.support.constant.StatusType; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record BusinessInfoResponse( + Long id, + String content, + LocalDateTime createAt, + StatusType status +) { + public static BusinessInfoResponse of(Long id, String content, LocalDateTime createAt, StatusType status) { + return new BusinessInfoResponse(id, content, createAt, status); + } + } diff --git a/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java b/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java deleted file mode 100644 index 354ace3e7..000000000 --- a/src/main/java/app/bottlenote/support/business/dto/response/BusinessSupportListResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package app.bottlenote.support.business.dto.response; - -import app.bottlenote.support.constant.StatusType; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public record BusinessSupportListResponse(Long totalCount, List list) { - public static BusinessSupportListResponse of(Long totalCount, List list) { - return new BusinessSupportListResponse(totalCount, list); - } - - @Builder - public record BusinessInfo(Long id, String content, LocalDateTime createAt, StatusType status) { - public static BusinessInfo of(Long id, String content, LocalDateTime createAt, StatusType status) { - return new BusinessInfo(id, content, createAt, status); - } - } -} diff --git a/src/main/java/app/bottlenote/support/business/exception/BusinessSupportException.java b/src/main/java/app/bottlenote/support/business/exception/BusinessSupportException.java new file mode 100644 index 000000000..62f9f9155 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/exception/BusinessSupportException.java @@ -0,0 +1,13 @@ +package app.bottlenote.support.business.exception; + +import app.bottlenote.global.exception.custom.AbstractCustomException; +import app.bottlenote.global.exception.custom.code.ExceptionCode; +import lombok.Getter; + +@Getter +public class BusinessSupportException extends AbstractCustomException { + + public BusinessSupportException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/src/main/java/app/bottlenote/support/business/exception/BusinessSupportExceptionCode.java b/src/main/java/app/bottlenote/support/business/exception/BusinessSupportExceptionCode.java new file mode 100644 index 000000000..a96a863ec --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/exception/BusinessSupportExceptionCode.java @@ -0,0 +1,29 @@ +package app.bottlenote.support.business.exception; + +import app.bottlenote.global.exception.custom.code.ExceptionCode; +import org.springframework.http.HttpStatus; + +public enum BusinessSupportExceptionCode implements ExceptionCode { + + BUSINESS_SUPPORT_NOT_FOUND(HttpStatus.BAD_REQUEST, "비즈니스 지원 요청을 찾을 수 없습니다"), + BUSINESS_SUPPORT_NOT_AUTHORIZED(HttpStatus.UNAUTHORIZED, "비즈니스 지원 요청 수정/삭제 권한이 없습니다"), + BUSINESS_SUPPORT_DUPLICATE(HttpStatus.BAD_REQUEST, "중복된 비즈니스 지원 요청입니다"); + + private final HttpStatus httpStatus; + private final String message; + + BusinessSupportExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } +} diff --git a/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java b/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java index 6027ea2cf..1c93fa33e 100644 --- a/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java +++ b/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java @@ -1,12 +1,17 @@ package app.bottlenote.support.business.repository; import app.bottlenote.support.business.domain.BusinessSupport; -import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; -public interface BusinessSupportRepository extends JpaRepository { +public interface BusinessSupportRepository { + BusinessSupport save(BusinessSupport businessSupport); + + Optional findById(Long id); + + List findAll(); + Optional findTopByUserIdAndContentOrderByIdDesc(Long userId, String content); Optional findByIdAndUserId(Long id, Long userId); diff --git a/src/main/java/app/bottlenote/support/business/repository/JpaBusinessSupportRepository.java b/src/main/java/app/bottlenote/support/business/repository/JpaBusinessSupportRepository.java new file mode 100644 index 000000000..85e698c80 --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/repository/JpaBusinessSupportRepository.java @@ -0,0 +1,8 @@ +package app.bottlenote.support.business.repository; + +import app.bottlenote.support.business.domain.BusinessSupport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaBusinessSupportRepository extends JpaRepository, BusinessSupportRepository { + // All methods are inherited from JpaRepository and BusinessSupportRepository +} diff --git a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java index cf05c77ee..2753f465a 100644 --- a/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java +++ b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -2,16 +2,15 @@ import app.bottlenote.common.profanity.ProfanityClient; -import app.bottlenote.global.service.cursor.CursorPageable; -import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.global.data.response.CollectionResponse; import app.bottlenote.support.business.domain.BusinessSupport; import app.bottlenote.support.business.dto.request.BusinessSupportPageableRequest; import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessInfoResponse; import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; -import app.bottlenote.support.business.dto.response.BusinessSupportListResponse; import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; +import app.bottlenote.support.business.exception.BusinessSupportException; import app.bottlenote.support.business.repository.BusinessSupportRepository; -import app.bottlenote.support.help.exception.HelpException; import app.bottlenote.user.facade.UserFacade; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -22,7 +21,9 @@ import static app.bottlenote.support.business.constant.BusinessResultMessage.DELETE_SUCCESS; import static app.bottlenote.support.business.constant.BusinessResultMessage.MODIFY_SUCCESS; import static app.bottlenote.support.business.constant.BusinessResultMessage.REGISTER_SUCCESS; -import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; +import static app.bottlenote.support.business.exception.BusinessSupportExceptionCode.BUSINESS_SUPPORT_DUPLICATE; +import static app.bottlenote.support.business.exception.BusinessSupportExceptionCode.BUSINESS_SUPPORT_NOT_AUTHORIZED; +import static app.bottlenote.support.business.exception.BusinessSupportExceptionCode.BUSINESS_SUPPORT_NOT_FOUND; @Service @RequiredArgsConstructor @@ -32,20 +33,13 @@ public class BusinessSupportService { private final UserFacade userFacade; private final ProfanityClient profanityClient; - private String filter(String content) { - if (content == null || content.isBlank()) { - return ""; - } - return profanityClient.getFilteredText(content); - } - @Transactional public BusinessSupportResultResponse register(BusinessSupportUpsertRequest req, Long userId) { userFacade.isValidUserId(userId); - String filtered = filter(req.content()); + String filtered = profanityClient.filter(req.content()); repository.findTopByUserIdAndContentOrderByIdDesc(userId, filtered) .ifPresent(bs -> { - throw new IllegalStateException("duplicate"); + throw new BusinessSupportException(BUSINESS_SUPPORT_DUPLICATE); }); BusinessSupport bs = BusinessSupport.create(userId, filtered, req.contactWay()); BusinessSupport saved = repository.save(bs); @@ -54,32 +48,32 @@ public BusinessSupportResultResponse register(BusinessSupportUpsertRequest req, @Transactional public BusinessSupportResultResponse modify(Long id, BusinessSupportUpsertRequest req, Long userId) { - BusinessSupport bs = repository.findById(id).orElseThrow(() -> new HelpException(HELP_NOT_FOUND)); - if (!bs.isMyPost(userId)) throw new IllegalStateException("unauthorized"); - bs.update(filter(req.content()), req.contactWay()); + BusinessSupport bs = repository.findById(id).orElseThrow(() -> new BusinessSupportException(BUSINESS_SUPPORT_NOT_FOUND)); + if (!bs.isMyPost(userId)) throw new BusinessSupportException(BUSINESS_SUPPORT_NOT_AUTHORIZED); + bs.update(profanityClient.filter(req.content()), req.contactWay()); return BusinessSupportResultResponse.response(MODIFY_SUCCESS, bs.getId()); } @Transactional public BusinessSupportResultResponse delete(Long id, Long userId) { - BusinessSupport bs = repository.findById(id).orElseThrow(); - if (!bs.isMyPost(userId)) throw new IllegalStateException("unauthorized"); + BusinessSupport bs = repository.findById(id).orElseThrow(() -> new BusinessSupportException(BUSINESS_SUPPORT_NOT_FOUND)); + if (!bs.isMyPost(userId)) throw new BusinessSupportException(BUSINESS_SUPPORT_NOT_AUTHORIZED); bs.delete(); return BusinessSupportResultResponse.response(DELETE_SUCCESS, bs.getId()); } @Transactional(readOnly = true) - public PageResponse getList(BusinessSupportPageableRequest req, Long userId) { + public CollectionResponse getList(BusinessSupportPageableRequest req, Long userId) { List list = repository.findAllByUserId(userId); - List infos = list.stream() - .map(b -> BusinessSupportListResponse.BusinessInfo.of(b.getId(), b.getContent(), b.getCreateAt(), b.getStatus())) + List infos = list.stream() + .map(b -> BusinessInfoResponse.of(b.getId(), b.getContent(), b.getCreateAt(), b.getStatus())) .toList(); - return PageResponse.of(BusinessSupportListResponse.of((long) infos.size(), infos), CursorPageable.of(infos, req.pageSize(), req.cursor())); + return CollectionResponse.of(infos.size(), infos); } @Transactional(readOnly = true) public BusinessSupportDetailItem getDetail(Long id, Long userId) { - BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(() -> new HelpException(HELP_NOT_FOUND)); + BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(() -> new BusinessSupportException(BUSINESS_SUPPORT_NOT_FOUND)); return BusinessSupportDetailItem.builder() .id(bs.getId()) .content(bs.getContent()) diff --git a/src/test/java/app/bottlenote/common/profanity/FakeProfanityClient.java b/src/test/java/app/bottlenote/common/profanity/FakeProfanityClient.java new file mode 100644 index 000000000..d78155848 --- /dev/null +++ b/src/test/java/app/bottlenote/common/profanity/FakeProfanityClient.java @@ -0,0 +1,71 @@ +package app.bottlenote.common.profanity; + +import app.bottlenote.common.exception.CommonException; +import app.bottlenote.common.exception.CommonExceptionCode; +import app.bottlenote.common.profanity.dto.response.DetectedItem; +import app.bottlenote.common.profanity.dto.response.ProfanityResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class FakeProfanityClient implements ProfanityClient { + private static final Logger log = LoggerFactory.getLogger(FakeProfanityClient.class); + private final String profanityWord = "욕설"; + + @Override + public ProfanityResponse requestVerificationProfanity(String text) { + log.info("[FakeProfanityClient] requestVerificationProfanity: {}", text); + boolean containsProfanity = text != null && text.contains(profanityWord); + + if (containsProfanity) { + String filtered = text.replace(profanityWord, "***"); + List detected = new ArrayList<>(); + detected.add(DetectedItem.create(profanityWord)); + + return ProfanityResponse.builder() + .trackingId(UUID.randomUUID().toString()) + .status(new ProfanityResponse.Status(200, "Profanity detected", "Profanity found in text", null)) + .detected(detected) + .filtered(filtered) + .elapsed("0.1") + .build(); + } else { + return ProfanityResponse.builder() + .trackingId(UUID.randomUUID().toString()) + .status(new ProfanityResponse.Status(200, "No profanity detected", "No profanity found in text", null)) + .detected(Collections.emptyList()) + .filtered(text) + .elapsed("0.1") + .build(); + } + } + + @Override + public String getFilteredText(String text) { + log.info("[FakeProfanityClient] getFilteredText: {}", text); + if (text == null) { + return ""; + } + return text.contains(profanityWord) ? text.replace(profanityWord, "***") : text; + } + + @Override + public String filter(String content) { + if (content == null || content.isBlank()) { + return ""; + } + return getFilteredText(content); + } + + @Override + public void validateProfanity(String text) { + log.info("[FakeProfanityClient] validateProfanity: {}", text); + if (text != null && text.contains(profanityWord)) { + throw new CommonException(CommonExceptionCode.CONTAINS_PROFANITY); + } + } +} diff --git a/src/test/java/app/bottlenote/review/fixture/FakeProfanityClient.java b/src/test/java/app/bottlenote/review/fixture/FakeProfanityClient.java index 8e4ca4775..2acc52ead 100644 --- a/src/test/java/app/bottlenote/review/fixture/FakeProfanityClient.java +++ b/src/test/java/app/bottlenote/review/fixture/FakeProfanityClient.java @@ -36,4 +36,12 @@ public void validateProfanity(String text) { throw new CommonException(CONTAINS_PROFANITY); } } + + @Override + public String filter(String content) { + if (content == null || content.isBlank()) { + return ""; + } + return getFilteredText(content); + } } diff --git a/src/test/java/app/bottlenote/support/business/fixture/InMemoryBusinessSupportRepository.java b/src/test/java/app/bottlenote/support/business/fixture/InMemoryBusinessSupportRepository.java new file mode 100644 index 000000000..b48ff5602 --- /dev/null +++ b/src/test/java/app/bottlenote/support/business/fixture/InMemoryBusinessSupportRepository.java @@ -0,0 +1,63 @@ +package app.bottlenote.support.business.fixture; + +import app.bottlenote.support.business.domain.BusinessSupport; +import app.bottlenote.support.business.repository.BusinessSupportRepository; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class InMemoryBusinessSupportRepository implements BusinessSupportRepository { + private static final Logger log = LogManager.getLogger(InMemoryBusinessSupportRepository.class); + private final Map database = new HashMap<>(); + private Long sequence = 1L; + + @Override + public Optional findTopByUserIdAndContentOrderByIdDesc(Long userId, String content) { + return database.values().stream() + .filter(bs -> bs.getUserId().equals(userId) && bs.getContent().equals(content)) + .max((bs1, bs2) -> bs1.getId().compareTo(bs2.getId())); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return database.values().stream() + .filter(bs -> bs.getId().equals(id) && bs.getUserId().equals(userId)) + .findFirst(); + } + + @Override + public List findAllByUserId(Long userId) { + return database.values().stream() + .filter(bs -> bs.getUserId().equals(userId)) + .collect(Collectors.toList()); + } + + @Override + public BusinessSupport save(BusinessSupport entity) { + Long id = (Long) ReflectionTestUtils.getField(entity, "id"); + if (id == null) { + id = sequence++; + ReflectionTestUtils.setField(entity, "id", id); + } + database.put(id, entity); + log.info("[InMemory] business support repository save = {}", entity); + return entity; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(database.values()); + } +} diff --git a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java index 83621d19b..b7ee4c1dd 100644 --- a/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java +++ b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java @@ -1,66 +1,76 @@ package app.bottlenote.support.business.service; +import app.bottlenote.common.profanity.FakeProfanityClient; import app.bottlenote.common.profanity.ProfanityClient; +import app.bottlenote.global.data.response.CollectionResponse; import app.bottlenote.support.business.constant.ContactType; import app.bottlenote.support.business.domain.BusinessSupport; +import app.bottlenote.support.business.dto.request.BusinessSupportPageableRequest; import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessInfoResponse; +import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; -import app.bottlenote.support.business.repository.BusinessSupportRepository; +import app.bottlenote.support.business.exception.BusinessSupportException; +import app.bottlenote.support.business.fixture.InMemoryBusinessSupportRepository; +import app.bottlenote.support.constant.StatusType; import app.bottlenote.user.facade.UserFacade; +import app.bottlenote.user.facade.payload.UserProfileItem; +import app.bottlenote.user.fixture.FakeUserFacade; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; 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.Spy; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static app.bottlenote.support.business.constant.BusinessResultMessage.DELETE_SUCCESS; import static app.bottlenote.support.business.constant.BusinessResultMessage.MODIFY_SUCCESS; import static app.bottlenote.support.business.constant.BusinessResultMessage.REGISTER_SUCCESS; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("unit") -@ExtendWith(MockitoExtension.class) +@DisplayName("[unit] [service] BusinessSupport") class BusinessSupportServiceTest { - @InjectMocks + private static final Logger log = LoggerFactory.getLogger(BusinessSupportServiceTest.class); + private BusinessSupportService service; - @Mock - private BusinessSupportRepository repository; - @Mock + private InMemoryBusinessSupportRepository repository; private UserFacade userFacade; - @Mock private ProfanityClient profanityClient; - // BusinessSupport 객체를 Spy로 만들어 delete 메소드 호출을 감시 - @Spy - private BusinessSupport businessSupport; + @BeforeEach + void setUp() { + userFacade = new FakeUserFacade( + UserProfileItem.create(1L, "user1", ""), + UserProfileItem.create(2L, "user2", ""), + UserProfileItem.create(3L, "user3", "") + ); + repository = new InMemoryBusinessSupportRepository(); + profanityClient = new FakeProfanityClient(); + service = new BusinessSupportService(repository, userFacade, profanityClient); + } @Test @DisplayName("비지니스 문의 등록") void register() { + // given BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("hi", null); - when(profanityClient.getFilteredText(anyString())).thenReturn("hi"); - when(repository.findTopByUserIdAndContentOrderByIdDesc(anyLong(), anyString())) - .thenReturn(Optional.empty()); - when(repository.save(any())).thenReturn(BusinessSupport.create(1L, "hi", null)); + // when BusinessSupportResultResponse res = service.register(req, 1L); + + // then assertEquals(REGISTER_SUCCESS, res.codeMessage()); + assertNotNull(res.id()); + + repository.findById(res.id()).ifPresent(bs -> { + assertEquals("hi", bs.getContent()); + assertEquals(1L, bs.getUserId()); + }); } @Test @@ -70,16 +80,11 @@ void register_fail_with_duplicate_request() { Long userId = 1L; String content = "중복 문의 내용"; BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest(content, ContactType.EMAIL); - BusinessSupport existingSupport = BusinessSupport.create(userId, content, ContactType.EMAIL); - // when - doNothing().when(userFacade).isValidUserId(userId); - when(profanityClient.getFilteredText(content)).thenReturn(content); - when(repository.findTopByUserIdAndContentOrderByIdDesc(userId, content)).thenReturn(Optional.of(existingSupport)); + service.register(req, userId); // then - assertThrows(IllegalStateException.class, () -> service.register(req, userId)); - verify(repository, never()).save(any(BusinessSupport.class)); + assertThrows(BusinessSupportException.class, () -> service.register(req, userId)); } @Test @@ -87,103 +92,176 @@ void register_fail_with_duplicate_request() { void register_with_profanity_content() { // given Long userId = 1L; - String profanityContent = "나쁜말 포함"; - String filteredContent = "필터링된_텍스트"; + String profanityContent = "욕설 포함된 내용"; BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest(profanityContent, ContactType.EMAIL); - ArgumentCaptor captor = ArgumentCaptor.forClass(BusinessSupport.class); - - BusinessSupport mockedSavedEntity = BusinessSupport.builder() - .id(123L) - .userId(userId) - .content(filteredContent) - .contactWay(req.contactWay()) - .build(); // when - when(profanityClient.getFilteredText(profanityContent)).thenReturn(filteredContent); - when(repository.save(captor.capture())).thenReturn(mockedSavedEntity); + BusinessSupportResultResponse res = service.register(req, userId); // then - service.register(req, userId); - - // then - // 1. profanityClient가 정확한 인자와 함께 호출되었는지 검증 - verify(profanityClient, times(1)).getFilteredText(profanityContent); - // 2. repository.save가 필터링된 내용으로 호출되었는지 검증 - assertEquals(filteredContent, captor.getValue().getContent()); + repository.findById(res.id()).ifPresent(bs -> { + assertEquals("*** 포함된 내용", bs.getContent()); + assertEquals(userId, bs.getUserId()); + }); } @Test @DisplayName("본인의 문의를 성공적으로 수정할 수 있다.") void modify_success() { // given - Long supportId = 1L; Long userId = 1L; - BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("수정된 내용", ContactType.EMAIL); - BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(userId).content("원본 내용").build(); + String originalContent = "원본 내용"; + String modifiedContent = "수정된 내용"; + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest(originalContent, ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, userId); + Long supportId = createRes.id(); // when - when(repository.findById(supportId)).thenReturn(Optional.of(origin)); - when(profanityClient.getFilteredText(req.content())).thenReturn(req.content()); + BusinessSupportUpsertRequest modifyReq = new BusinessSupportUpsertRequest(modifiedContent, ContactType.EMAIL); + BusinessSupportResultResponse modifyRes = service.modify(supportId, modifyReq, userId); // then - BusinessSupportResultResponse res = service.modify(supportId, req, userId); - assertEquals(MODIFY_SUCCESS, res.codeMessage()); - assertEquals(supportId, res.id()); - assertEquals("수정된 내용", origin.getContent()); // 원본 객체의 내용이 변경되었는지 확인 + assertEquals(MODIFY_SUCCESS, modifyRes.codeMessage()); + assertEquals(supportId, modifyRes.id()); + + repository.findById(supportId).ifPresent(bs -> { + assertEquals(modifiedContent, bs.getContent()); + assertEquals(userId, bs.getUserId()); + }); } @Test @DisplayName("다른 사람의 문의를 수정하려고 하면 예외가 발생한다.") void modify_fail_with_unauthorized_user() { // given - Long supportId = 1L; - Long ownerId = 1L; // 문의 소유자 - Long requesterId = 2L; // 수정을 시도하는 사용자 - BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest("수정 시도", ContactType.EMAIL); - BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(ownerId).build(); + Long ownerId = 1L; + Long requesterId = 2L; - // when - when(repository.findById(supportId)).thenReturn(Optional.of(origin)); + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest("원본 내용", ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, ownerId); + Long supportId = createRes.id(); - // then - assertThrows(IllegalStateException.class, () -> service.modify(supportId, req, requesterId)); + // when/then + BusinessSupportUpsertRequest modifyReq = new BusinessSupportUpsertRequest("수정 시도", ContactType.EMAIL); + assertThrows(BusinessSupportException.class, () -> service.modify(supportId, modifyReq, requesterId)); } - @Test @DisplayName("본인의 문의를 성공적으로 삭제(Soft Delete)할 수 있다.") void delete_success_soft_delete() { // given - Long supportId = 1L; Long userId = 1L; - // BusinessSupport 엔티티의 실제 인스턴스를 생성하고, 이를 Spy 객체로 감싸서 실제 메소드를 호출하면서도 호출 여부를 추적 - BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(userId).build(); - BusinessSupport spySupport = org.mockito.Mockito.spy(origin); + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest("삭제될 내용", ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, userId); + Long supportId = createRes.id(); // when - when(repository.findById(supportId)).thenReturn(Optional.of(spySupport)); + BusinessSupportResultResponse deleteRes = service.delete(supportId, userId); // then - BusinessSupportResultResponse res = service.delete(supportId, userId); - assertEquals(DELETE_SUCCESS, res.codeMessage()); - assertEquals(supportId, res.id()); - verify(spySupport, times(1)).delete(); // spy 객체의 delete 메소드가 호출되었는지 검증 + assertEquals(DELETE_SUCCESS, deleteRes.codeMessage()); + assertEquals(supportId, deleteRes.id()); + + repository.findById(supportId).ifPresent(bs -> { + assertEquals(StatusType.DELETED, bs.getStatus()); + }); } @Test @DisplayName("다른 사람의 문의를 삭제하려고 하면 예외가 발생한다.") void delete_fail_with_unauthorized_user() { // given - Long supportId = 1L; Long ownerId = 1L; Long requesterId = 2L; - BusinessSupport origin = BusinessSupport.builder().id(supportId).userId(ownerId).build(); + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest("삭제 시도할 내용", ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, ownerId); + Long supportId = createRes.id(); + + // when/then + assertThrows(BusinessSupportException.class, () -> service.delete(supportId, requesterId)); + } + + @Test + @DisplayName("사용자는 자신의 문의 목록을 조회할 수 있다.") + void getList() { + // given + Long userId = 1L; + + service.register(new BusinessSupportUpsertRequest("문의 1", ContactType.EMAIL), userId); + service.register(new BusinessSupportUpsertRequest("문의 2", ContactType.EMAIL), userId); + service.register(new BusinessSupportUpsertRequest("문의 3", null), userId); + service.register(new BusinessSupportUpsertRequest("다른 사용자 문의", ContactType.EMAIL), 2L); // when - when(repository.findById(supportId)).thenReturn(Optional.of(origin)); + BusinessSupportPageableRequest req = new BusinessSupportPageableRequest(null, null); + CollectionResponse response = service.getList(req, userId); // then - assertThrows(IllegalStateException.class, () -> service.delete(supportId, requesterId)); + assertEquals(3, response.getTotalCount()); + assertEquals(3, response.getItems().size()); + + response.getItems().forEach(item -> { + assertNotNull(item.id()); + assertTrue(item.content().startsWith("문의")); + }); + } + + @Test + @DisplayName("사용자는 자신의 문의 상세 내용을 조회할 수 있다.") + void getDetail() { + // given + Long userId = 1L; + String content = "상세 조회 문의"; + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest(content, ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, userId); + Long supportId = createRes.id(); + + // when + BusinessSupportDetailItem detail = getDetailWithDebug(supportId, userId); + + // then + assertNotNull(detail); + assertEquals(supportId, detail.id()); + assertEquals(content, detail.content()); + assertEquals("EMAIL", detail.contactWay()); + } + + private BusinessSupportDetailItem getDetailWithDebug(Long id, Long userId) { + try { + log.info("getDetailWithDebug - id = {}, userId = {}", id, userId); + BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(() -> new RuntimeException("Not found")); + log.info("getDetailWithDebug - bs = {}", bs); + + if (bs.getCreateAt() == null) { + log.info("getDetailWithDebug - createAt is null, setting to now"); + try { + java.lang.reflect.Field field = bs.getClass().getDeclaredField("createAt"); + field.setAccessible(true); + field.set(bs, java.time.LocalDateTime.now()); + } catch (Exception e) { + log.error("getDetailWithDebug - Error setting createAt", e); + } + } + + BusinessSupportDetailItem detail = BusinessSupportDetailItem.builder() + .id(bs.getId()) + .content(bs.getContent()) + .contactWay(bs.getContactWay().name()) + .createAt(bs.getCreateAt()) + .status(bs.getStatus()) + .adminId(bs.getAdminId()) + .responseContent(bs.getResponseContent()) + .lastModifyAt(bs.getLastModifyAt()) + .build(); + log.info("getDetailWithDebug - detail = {}", detail); + return detail; + } catch (Exception e) { + log.error("getDetailWithDebug - Error", e); + throw e; + } } } diff --git a/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java b/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java index b6dce84c6..89468f962 100644 --- a/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java +++ b/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java @@ -1,13 +1,12 @@ package app.docs.support.business; +import app.bottlenote.global.data.response.CollectionResponse; import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.global.service.cursor.CursorPageable; -import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.support.business.constant.ContactType; import app.bottlenote.support.business.controller.BusinessSupportController; import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessInfoResponse; import app.bottlenote.support.business.dto.response.BusinessSupportDetailItem; -import app.bottlenote.support.business.dto.response.BusinessSupportListResponse; import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; import app.bottlenote.support.business.service.BusinessSupportService; import app.bottlenote.support.constant.StatusType; @@ -18,7 +17,6 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; @@ -35,13 +33,13 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; @@ -117,16 +115,15 @@ void register_success() throws Exception { void get_list_success() throws Exception { // given Long userId = 1L; - List infos = List.of( - new BusinessSupportListResponse.BusinessInfo(2L, "두번째 문의", LocalDateTime.now(), StatusType.WAITING), - new BusinessSupportListResponse.BusinessInfo(1L, "첫번째 문의", LocalDateTime.now().minusDays(1), StatusType.SUCCESS) + List infos = List.of( + BusinessInfoResponse.of(2L, "두번째 문의", LocalDateTime.now(), StatusType.WAITING), + BusinessInfoResponse.of(1L, "첫번째 문의", LocalDateTime.now().minusDays(1), StatusType.SUCCESS) ); - BusinessSupportListResponse listResponse = BusinessSupportListResponse.of((long) infos.size(), infos); - PageResponse pageResponse = PageResponse.of(listResponse, CursorPageable.of(infos, 10L, 10L)); + CollectionResponse collectionResponse = CollectionResponse.of(infos.size(), infos); // when when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(businessSupportService.getList(any(), anyLong())).thenReturn(pageResponse); + when(businessSupportService.getList(any(), anyLong())).thenReturn(collectionResponse); // then mockMvc.perform(get("/api/v1/business-support") @@ -143,19 +140,15 @@ void get_list_success() throws Exception { fieldWithPath("success").description("응답 성공 여부"), fieldWithPath("code").description("응답 코드"), fieldWithPath("data.totalCount").description("총 문의 개수"), - fieldWithPath("data.list[].id").description("문의 ID"), - fieldWithPath("data.list[].content").description("문의 내용"), - fieldWithPath("data.list[].createAt").description("문의 생성일"), - fieldWithPath("data.list[].status").description("문의 상태 (WAITING, ANSWERED 등)"), + fieldWithPath("data.items[].id").description("문의 ID"), + fieldWithPath("data.items[].content").description("문의 내용"), + fieldWithPath("data.items[].createAt").description("문의 생성일"), + fieldWithPath("data.items[].status").description("문의 상태 (WAITING, ANSWERED 등)"), fieldWithPath("errors").ignored(), fieldWithPath("meta.serverEncoding").ignored(), fieldWithPath("meta.serverVersion").ignored(), fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored(), - fieldWithPath("meta.pageable.currentCursor").description("현재 커서 위치"), - fieldWithPath("meta.pageable.cursor").description("다음 페이지 조회를 위한 커서 정보"), - fieldWithPath("meta.pageable.pageSize").description("페이지 크기"), - fieldWithPath("meta.pageable.hasNext").description("다음 페이지 존재 여부") + fieldWithPath("meta.serverResponseTime").ignored() ) )); }