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 2a6220c7c..45ca7031d 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -190,6 +190,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/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/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) 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/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/controller/BusinessSupportController.java b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java new file mode 100644 index 000000000..9d80ce16f --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/controller/BusinessSupportController.java @@ -0,0 +1,65 @@ +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.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.service.BusinessSupportService; +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 { + + 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 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 UserException(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..a508b2eaf --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/domain/BusinessSupport.java @@ -0,0 +1,86 @@ +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; +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") + @Enumerated(EnumType.STRING) + private ContactType contactWay; + + @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 + private BusinessSupport(Long id, Long userId, String content, ContactType 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, ContactType contactWay) { + return BusinessSupport.builder() + .userId(userId) + .content(content) + .contactWay(contactWay) + .build(); + } + + public void update(String content, ContactType 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..fe3bb20ec --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/dto/request/BusinessSupportUpsertRequest.java @@ -0,0 +1,15 @@ +package app.bottlenote.support.business.dto.request; + +import app.bottlenote.support.business.constant.ContactType; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record BusinessSupportUpsertRequest( + @Size(max = 500) + String content, + + ContactType contactWay +) { +} + 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/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/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/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 new file mode 100644 index 000000000..1c93fa33e --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/repository/BusinessSupportRepository.java @@ -0,0 +1,20 @@ +package app.bottlenote.support.business.repository; + +import app.bottlenote.support.business.domain.BusinessSupport; + +import java.util.List; +import java.util.Optional; + +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); + + List findAllByUserId(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 new file mode 100644 index 000000000..2753f465a --- /dev/null +++ b/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -0,0 +1,88 @@ +package app.bottlenote.support.business.service; + + +import app.bottlenote.common.profanity.ProfanityClient; +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.BusinessSupportResultResponse; +import app.bottlenote.support.business.exception.BusinessSupportException; +import app.bottlenote.support.business.repository.BusinessSupportRepository; +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.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 +public class BusinessSupportService { + + private final BusinessSupportRepository repository; + private final UserFacade userFacade; + private final ProfanityClient profanityClient; + + @Transactional + public BusinessSupportResultResponse register(BusinessSupportUpsertRequest req, Long userId) { + userFacade.isValidUserId(userId); + String filtered = profanityClient.filter(req.content()); + repository.findTopByUserIdAndContentOrderByIdDesc(userId, filtered) + .ifPresent(bs -> { + throw new BusinessSupportException(BUSINESS_SUPPORT_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 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(() -> 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 CollectionResponse getList(BusinessSupportPageableRequest req, Long userId) { + List list = repository.findAllByUserId(userId); + List infos = list.stream() + .map(b -> BusinessInfoResponse.of(b.getId(), b.getContent(), b.getCreateAt(), b.getStatus())) + .toList(); + return CollectionResponse.of(infos.size(), infos); + } + + @Transactional(readOnly = true) + public BusinessSupportDetailItem getDetail(Long id, Long userId) { + BusinessSupport bs = repository.findByIdAndUserId(id, userId).orElseThrow(() -> new BusinessSupportException(BUSINESS_SUPPORT_NOT_FOUND)); + return 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(); + } +} 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/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/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/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 new file mode 100644 index 000000000..b7ee4c1dd --- /dev/null +++ b/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java @@ -0,0 +1,267 @@ +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.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.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.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +@DisplayName("[unit] [service] BusinessSupport") +class BusinessSupportServiceTest { + + private static final Logger log = LoggerFactory.getLogger(BusinessSupportServiceTest.class); + + private BusinessSupportService service; + private InMemoryBusinessSupportRepository repository; + private UserFacade userFacade; + private ProfanityClient profanityClient; + + @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 + 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 + @DisplayName("동일한 내용의 문의를 중복 등록하면 예외가 발생한다.") + void register_fail_with_duplicate_request() { + // given + Long userId = 1L; + String content = "중복 문의 내용"; + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest(content, ContactType.EMAIL); + + service.register(req, userId); + + // then + assertThrows(BusinessSupportException.class, () -> service.register(req, userId)); + } + + @Test + @DisplayName("부적절한 단어가 포함된 내용은 필터링하여 등록한다.") + void register_with_profanity_content() { + // given + Long userId = 1L; + String profanityContent = "욕설 포함된 내용"; + BusinessSupportUpsertRequest req = new BusinessSupportUpsertRequest(profanityContent, ContactType.EMAIL); + + // when + BusinessSupportResultResponse res = service.register(req, userId); + + // then + repository.findById(res.id()).ifPresent(bs -> { + assertEquals("*** 포함된 내용", bs.getContent()); + assertEquals(userId, bs.getUserId()); + }); + } + + @Test + @DisplayName("본인의 문의를 성공적으로 수정할 수 있다.") + void modify_success() { + // given + Long userId = 1L; + String originalContent = "원본 내용"; + String modifiedContent = "수정된 내용"; + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest(originalContent, ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, userId); + Long supportId = createRes.id(); + + // when + BusinessSupportUpsertRequest modifyReq = new BusinessSupportUpsertRequest(modifiedContent, ContactType.EMAIL); + BusinessSupportResultResponse modifyRes = service.modify(supportId, modifyReq, userId); + + // then + 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 ownerId = 1L; + Long requesterId = 2L; + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest("원본 내용", ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, ownerId); + Long supportId = createRes.id(); + + // 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 userId = 1L; + + BusinessSupportUpsertRequest createReq = new BusinessSupportUpsertRequest("삭제될 내용", ContactType.EMAIL); + BusinessSupportResultResponse createRes = service.register(createReq, userId); + Long supportId = createRes.id(); + + // when + BusinessSupportResultResponse deleteRes = service.delete(supportId, userId); + + // then + 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 ownerId = 1L; + Long requesterId = 2L; + + 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 + BusinessSupportPageableRequest req = new BusinessSupportPageableRequest(null, null); + CollectionResponse response = service.getList(req, userId); + + // then + 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 new file mode 100644 index 000000000..89468f962 --- /dev/null +++ b/src/test/java/app/docs/support/business/RestDocsBusinessSupportControllerTest.java @@ -0,0 +1,286 @@ +package app.docs.support.business; + +import app.bottlenote.global.data.response.CollectionResponse; +import app.bottlenote.global.security.SecurityContextUtil; +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.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.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.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; +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( + BusinessInfoResponse.of(2L, "두번째 문의", LocalDateTime.now(), StatusType.WAITING), + BusinessInfoResponse.of(1L, "첫번째 문의", LocalDateTime.now().minusDays(1), StatusType.SUCCESS) + ); + CollectionResponse collectionResponse = CollectionResponse.of(infos.size(), infos); + + // when + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + when(businessSupportService.getList(any(), anyLong())).thenReturn(collectionResponse); + + // 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.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() + ) + )); + } + + @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() + ) + )); + } +}