diff --git a/src/main/java/com/back/domain/product/product/entity/Product.java b/src/main/java/com/back/domain/product/product/entity/Product.java index dc9c75eb..a90a8343 100644 --- a/src/main/java/com/back/domain/product/product/entity/Product.java +++ b/src/main/java/com/back/domain/product/product/entity/Product.java @@ -1,6 +1,7 @@ package com.back.domain.product.product.entity; import com.back.domain.product.category.entity.Category; +import com.back.domain.product.qna.entity.ProductQna; import com.back.domain.user.entity.User; import com.back.global.jpa.entity.BaseEntity; import jakarta.persistence.*; @@ -126,6 +127,10 @@ public class Product extends BaseEntity { @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)// 상품이 저장/삭제되면 매핑 중간테이블의 해당 데이터도 저장/삭제됨, 상품에서 특정 태그를 제거하면 DB에서 해당 매핑 데이터도 삭제됨. private Set productTags= new HashSet<>(); // 상품과 태그(스타일)의 중간 테이블. 하나의 상품에 동일한 태그를 중복으로 붙이는 걸 허용하지 않으므로 List말고 Set 사용 + @Builder.Default + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List productQnas = new ArrayList<>(); // 상품 Q&A + // 할인된 가격 계산 public int getDiscountPrice() { return price - (price * discountRate / 100); diff --git a/src/main/java/com/back/domain/product/product/service/ProductService.java b/src/main/java/com/back/domain/product/product/service/ProductService.java index 72bb84cc..ba45567c 100644 --- a/src/main/java/com/back/domain/product/product/service/ProductService.java +++ b/src/main/java/com/back/domain/product/product/service/ProductService.java @@ -386,7 +386,7 @@ private void checkProductOwner(Product product, User user) { } } // 존재하지 않는 상품 검증 - private Product getProductOrThrow(UUID productUuid) { + public Product getProductOrThrow(UUID productUuid) { return productRepository.findByProductUuid(productUuid) .orElseThrow(() -> new ServiceException("404", "존재하지 않는 상품입니다. UUID: " + productUuid)); } diff --git a/src/main/java/com/back/domain/product/qna/controller/ProductQnaController.java b/src/main/java/com/back/domain/product/qna/controller/ProductQnaController.java new file mode 100644 index 00000000..ed1ea6b0 --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/controller/ProductQnaController.java @@ -0,0 +1,159 @@ +package com.back.domain.product.qna.controller; + +import com.back.domain.product.qna.dto.request.ProductQnaRequestDto; +import com.back.domain.product.qna.dto.response.ProductQnaListResponseDto; +import com.back.domain.product.qna.dto.response.ProductQnaResponseDto; +import com.back.domain.product.qna.service.ProductQnaService; +import com.back.global.rsData.RsData; +import com.back.global.security.auth.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/products/qna/{productUuid}") +@RequiredArgsConstructor +@Tag(name = "상품 Q&A", description = "상품 Q&A 관련 API") +public class ProductQnaController { + + private final ProductQnaService productQnaService; + + /** 상품 Q&A 등록 */ + @PostMapping + @Operation( + summary = "상품 Q&A 등록", + responses = { + @ApiResponse( + responseCode = "200", + description = "상품 Q&A 등록 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema( + example = """ + { + "resultCode": "200", + "msg": "상품 Q&A가 성공적으로 등록되었습니다.", + "data": "550e8400-e29b-41d4-a716-446655440000" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (필수값 누락, 존재하지 않는 상품 등)", + content = @Content( + mediaType = "application/json", + schema = @Schema( + example = """ + { + "resultCode": "400", + "msg": "Q&A 카테고리는 필수입니다.", + "data": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema( + example = """ + { + "resultCode": "403", + "msg": "상품 Q&A 등록 권한이 없습니다.", + "data": null + } + """ + ) + ) + ) + } + ) + public ResponseEntity> createProductQna( + @PathVariable UUID productUuid, + @Valid @RequestBody ProductQnaRequestDto request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID createdProductUuid = productQnaService.createProductQna(productUuid, request, customUserDetails); + return ResponseEntity.ok(RsData.of("200", "상품 Q&A가 성공적으로 등록되었습니다.", createdProductUuid)); + } + + /** 상품 Q&A 상세 조회 */ + @GetMapping("/{productQnaId}") + @Operation( + summary = "상품 Q&A 상세 조회", + responses = { + @ApiResponse( + responseCode = "200", + description = "상품 Q&A 상세 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProductQnaResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "해당 상품 Q&A를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema( + example = """ + { + "resultCode": "404", + "msg": "해당 상품 Q&A를 찾을 수 없습니다.", + "data": null + } + """ + ) + ) + ) + } + ) + public ResponseEntity> getProductQnaDetail( + @PathVariable Long productQnaId) { + ProductQnaResponseDto responseDto = productQnaService.getProductQnaDetail(productQnaId); + return ResponseEntity.ok(RsData.of("200", "상품 Q&A 상세 조회 성공", responseDto)); + } + + /** 상품 Q&A 목록 조회 (페이지네이션) */ + @GetMapping("/list") + @Operation( + summary = "상품 Q&A 목록 조회 (페이지네이션)", + parameters = { + @Parameter(name = "qnaCategory", description = "Q&A 카테고리 (예: 배송, 상품, 교환/환불, 기타. '전체' 또는 미지정 시 전체 카테고리 조회)", example = "배송"), + @Parameter(name = "page", description = "페이지 번호", example = "1"), + @Parameter(name = "size", description = "페이지당 항목 수", example = "10") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "상품 Q&A 목록 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProductQnaListResponseDto.class) + ) + ) + } + ) + public ResponseEntity> getProductQnaList( + @PathVariable UUID productUuid, + @RequestParam(value = "qnaCategory", required = false) String qnaCategory, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size) { + ProductQnaListResponseDto responseDto = productQnaService.getProductQnaList(productUuid, qnaCategory, page, size); + return ResponseEntity.ok(RsData.of("200", "상품 Q&A 목록 조회 성공", responseDto)); + } +} diff --git a/src/main/java/com/back/domain/product/qna/dto/request/ProductQnaRequestDto.java b/src/main/java/com/back/domain/product/qna/dto/request/ProductQnaRequestDto.java new file mode 100644 index 00000000..d93ffc86 --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/dto/request/ProductQnaRequestDto.java @@ -0,0 +1,36 @@ +package com.back.domain.product.qna.dto.request; + +import com.back.global.s3.S3FileRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +/** + * 상품 Q&A 작성 요청을 받는 DTO + */ +public record ProductQnaRequestDto( + @Schema(description = "Q&A 카테고리", example = "배송") + @NotBlank(message = "Q&A 카테고리는 필수입니다.") + String qnaCategory, + + @Schema(description = "Q&A 제목", example = "배송 문의합니다.") + @NotBlank(message = "Q&A 제목은 필수입니다.") + String qnaTitle, + + @Schema(description = "Q&A 내용", example = "상품 배송이 언제쯤 시작될까요?") + @NotBlank(message = "Q&A 내용은 필수입니다.") + String qnaDescription, + + @Schema( + description = "첨부 이미지 파일 목록 (null 허용)", + example = "[" + + "{\"url\":\"https://example.com/image1.jpg\",\"type\":\"ADDITIONAL\",\"s3Key\":\"s3-key-1\",\"originalFileName\":\"image1.jpg\"}," + + "{\"url\":\"https://example.com/image2.jpg\",\"type\":\"ADDITIONAL\",\"s3Key\":\"s3-key-2\",\"originalFileName\":\"image2.jpg\"}" + + "]" + ) + @Valid + List qnaImages +) { +} diff --git a/src/main/java/com/back/domain/product/qna/dto/response/ProductQnaListResponseDto.java b/src/main/java/com/back/domain/product/qna/dto/response/ProductQnaListResponseDto.java new file mode 100644 index 00000000..93c076be --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/dto/response/ProductQnaListResponseDto.java @@ -0,0 +1,33 @@ +package com.back.domain.product.qna.dto.response; + +import com.back.domain.product.qna.entity.ProductQna; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 상품 Q&A 목록 조회 응답 DTO + */ +public record ProductQnaListResponseDto( + @Schema(description = "현재 페이지 번호", example = "1") + int currentPage, + @Schema(description = "총 페이지 수", example = "5") + int totalPages, + @Schema(description = "한 페이지당 항목 수", example = "10") + int pageSize, + @Schema(description = "총 항목 수", example = "45") + long totalElements, + @Schema(description = "상품 Q&A 목록") + List qnaList +) { + public static ProductQnaListResponseDto fromPage(Page page, List qnaList) { + return new ProductQnaListResponseDto( + page.getNumber() + 1, + page.getTotalPages(), + page.getSize(), + page.getTotalElements(), + qnaList + ); + } +} diff --git a/src/main/java/com/back/domain/product/qna/dto/response/ProductQnaResponseDto.java b/src/main/java/com/back/domain/product/qna/dto/response/ProductQnaResponseDto.java new file mode 100644 index 00000000..d3eeb671 --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/dto/response/ProductQnaResponseDto.java @@ -0,0 +1,28 @@ +package com.back.domain.product.qna.dto.response; + +import com.back.global.s3.UploadResultResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 상품 Q&A 상세 조회 응답 DTO + */ +@Schema(name = "ProductQnaResponseDto", description = "상품 Q&A 조회 응답 DTO") +public record ProductQnaResponseDto( + @Schema(description = "상품 Q&A 글 번호", example = "1") + Long id, + @Schema(description = "Q&A 카테고리", example = "배송") + String qnaCategory, + @Schema(description = "Q&A 제목", example = "배송 문의합니다.") + String qnaTitle, + @Schema(description = "Q&A 내용", example = "상품 배송이 언제쯤 시작될까요?") + String qnaDescription, + @Schema(description = "작성자 이름", example = "홍길동") + String authorName, + @Schema(description = "작성일", example = "23.10.26") + String createDate, + @Schema(description = "첨부 이미지 파일 목록", example = "[" + "{\"url\":\"https://example.com/image1.jpg\",\"type\":\"ADDITIONAL\",\"s3Key\":\"product-images/uuid1.png\",\"originalFileName\":\"example.png\"}," + "{\"url\":\"https://example.com/image2.jpg\",\"fileType\":\"ADDITIONAL\",\"s3Key\":\"product-images/uuid2.png\",\"originalFileName\":\"example2.png\"}," + "]" ) + List qnaImages +) { +} diff --git a/src/main/java/com/back/domain/product/qna/entity/ProductQna.java b/src/main/java/com/back/domain/product/qna/entity/ProductQna.java new file mode 100644 index 00000000..7f5036f1 --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/entity/ProductQna.java @@ -0,0 +1,45 @@ +package com.back.domain.product.qna.entity; + +import com.back.domain.product.product.entity.Product; +import com.back.domain.user.entity.User; +import com.back.global.jpa.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "product_qna") +public class ProductQna extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; // 상품 FK + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // Q&A 작성자 FK + + @Column(nullable = false) + private String qnaCategory; // 카테고리 + + @Column(nullable = false) + private String qnaTitle; // 제목 + + @Column(columnDefinition = "TEXT", nullable = false) + private String qnaDescription; // 내용 + + @Builder.Default + @OneToMany(mappedBy = "productQna", cascade = CascadeType.ALL, orphanRemoval = true) + private List productQnaImages = new ArrayList<>(); + + public void addProductQnaImage(ProductQnaImage productQnaImage) { + productQnaImages.add(productQnaImage); + productQnaImage.setProductQna(this); + } +} diff --git a/src/main/java/com/back/domain/product/qna/entity/ProductQnaImage.java b/src/main/java/com/back/domain/product/qna/entity/ProductQnaImage.java new file mode 100644 index 00000000..773cd614 --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/entity/ProductQnaImage.java @@ -0,0 +1,33 @@ +package com.back.domain.product.qna.entity; + +import com.back.global.jpa.entity.BaseEntity; +import com.back.global.s3.FileType; +import jakarta.persistence.*; +import lombok.*; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "product_qna_image") +public class ProductQnaImage extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_qna_id", nullable = false) + private ProductQna productQna; + + @Column(nullable = false) + private String fileUrl; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private FileType fileType; + + @Column(nullable = false) + private String s3Key; + + @Column(nullable = false) + private String originalFileName; +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/product/qna/repository/ProductQnaImageRepository.java b/src/main/java/com/back/domain/product/qna/repository/ProductQnaImageRepository.java new file mode 100644 index 00000000..b0f4092c --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/repository/ProductQnaImageRepository.java @@ -0,0 +1,7 @@ +package com.back.domain.product.qna.repository; + +import com.back.domain.product.qna.entity.ProductQnaImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductQnaImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/domain/product/qna/repository/ProductQnaRepository.java b/src/main/java/com/back/domain/product/qna/repository/ProductQnaRepository.java new file mode 100644 index 00000000..d7b66630 --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/repository/ProductQnaRepository.java @@ -0,0 +1,13 @@ +package com.back.domain.product.qna.repository; + +import com.back.domain.product.product.entity.Product; +import com.back.domain.product.qna.entity.ProductQna; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductQnaRepository extends JpaRepository { + Page findByProduct(Product product, Pageable pageable); + Page findByProductAndQnaCategory(Product product, String qnaCategory, Pageable pageable); +} + diff --git a/src/main/java/com/back/domain/product/qna/service/ProductQnaService.java b/src/main/java/com/back/domain/product/qna/service/ProductQnaService.java new file mode 100644 index 00000000..23b182fe --- /dev/null +++ b/src/main/java/com/back/domain/product/qna/service/ProductQnaService.java @@ -0,0 +1,147 @@ +package com.back.domain.product.qna.service; + +import com.back.domain.product.product.entity.Product; +import com.back.domain.product.product.service.ProductService; +import com.back.domain.product.qna.dto.request.ProductQnaRequestDto; +import com.back.domain.product.qna.dto.response.ProductQnaListResponseDto; +import com.back.domain.product.qna.dto.response.ProductQnaResponseDto; +import com.back.domain.product.qna.entity.ProductQna; +import com.back.domain.product.qna.entity.ProductQnaImage; +import com.back.domain.product.qna.repository.ProductQnaImageRepository; +import com.back.domain.product.qna.repository.ProductQnaRepository; +import com.back.domain.user.entity.User; +import com.back.global.exception.ServiceException; +import com.back.global.s3.S3FileRequest; +import com.back.global.s3.S3ValidationService; +import com.back.global.s3.UploadResultResponse; +import com.back.global.security.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ProductQnaService { + + private final ProductQnaRepository productQnaRepository; + private final ProductQnaImageRepository productQnaImageRepository; + private final S3ValidationService s3ValidationService; + private final ProductService productService; + + /** 상품 Q&A 등록 */ + @Transactional + public UUID createProductQna(UUID productUuid, ProductQnaRequestDto request, CustomUserDetails customUserDetails) { + Product product = productService.getProductOrThrow(productUuid); + User user = customUserDetails.getUser(); + + ProductQna productQna = ProductQna.builder() + .product(product) + .user(user) + .qnaCategory(request.qnaCategory()) + .qnaTitle(request.qnaTitle()) + .qnaDescription(request.qnaDescription()) + .build(); + + if (request.qnaImages() != null && !request.qnaImages().isEmpty()) { + // productQnaImage DB에 저장 + productQna.getProductQnaImages().addAll(buildProductQnaImages(productQna, request.qnaImages())); + } + + productQnaRepository.save(productQna); + return product.getProductUuid(); + } + + /** 상품 Q&A 상세 조회 */ + @Transactional(readOnly = true) + public ProductQnaResponseDto getProductQnaDetail(Long productQnaId) { + ProductQna productQna = productQnaRepository.findById(productQnaId) + .orElseThrow(() -> new ServiceException("404", "해당 상품 Q&A를 찾을 수 없습니다.")); + + // ProductQna의 이미지 리스트 + List qnaImages = productQna.getProductQnaImages().stream() + .map(image -> new UploadResultResponse(image.getFileUrl(), image.getFileType(), image.getS3Key(), image.getOriginalFileName())) + .toList(); + + // 날짜 포맷 "YY.MM.DD" 형식으로 변경 + String formattedCreateDate = productQna.getCreateDate().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + + // ProductQna 엔티티 -> DTO 변환 + return new ProductQnaResponseDto( + productQna.getId(), + productQna.getQnaCategory(), + productQna.getQnaTitle(), + productQna.getQnaDescription(), + productQna.getUser().getName(), + formattedCreateDate, + qnaImages + ); + } + + /** 상품 Q&A 목록 조회 (페이지네이션) */ + @Transactional(readOnly = true) + public ProductQnaListResponseDto getProductQnaList(UUID productUuid, @Nullable String qnaCategory, int page, int size) { + Product product = productService.getProductOrThrow(productUuid); + + // 페이지네이션 + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createDate")); + // 페이지네이션 정보에 따라 ProductQna 조회 + Page qnaPage; + + if (qnaCategory == null || qnaCategory.equals("전체")) { + qnaPage = productQnaRepository.findByProduct(product, pageable); + } else { + qnaPage = productQnaRepository.findByProductAndQnaCategory(product, qnaCategory, pageable); + } + + // 조회된 ProductQna -> DTO 변환 + List qnaList = qnaPage.getContent().stream() + .map(productQna -> { + // 이미지 + List qnaImages = productQna.getProductQnaImages().stream() + .map(image -> new UploadResultResponse(image.getFileUrl(), image.getFileType(), image.getS3Key(), image.getOriginalFileName())) + .toList(); + // 날짜 포맷팅 + String formattedCreateDate = productQna.getCreateDate().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + + return new ProductQnaResponseDto( + productQna.getId(), + productQna.getQnaCategory(), + productQna.getQnaTitle(), + productQna.getQnaDescription(), + productQna.getUser().getName(), + formattedCreateDate, + qnaImages + ); + }) + .toList(); + + return ProductQnaListResponseDto.fromPage(qnaPage, qnaList); + } + + /** 상품 Q&A 이미지 저장 */ + private List buildProductQnaImages(ProductQna productQna, List images) { + if (images == null || images.isEmpty()) return List.of(); + return images.stream() + .map(img -> { + s3ValidationService.validateFileExists(img.s3Key()); + return ProductQnaImage.builder() + .productQna(productQna) + .fileUrl(img.url()) + .fileType(img.type()) + .s3Key(img.s3Key()) + .originalFileName(img.originalFileName()) + .build(); + }).toList(); + } +} diff --git a/src/main/java/com/back/global/security/config/SecurityConfig.java b/src/main/java/com/back/global/security/config/SecurityConfig.java index d4f08907..1c0ee961 100644 --- a/src/main/java/com/back/global/security/config/SecurityConfig.java +++ b/src/main/java/com/back/global/security/config/SecurityConfig.java @@ -90,10 +90,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 공개 API .requestMatchers("/public/**").permitAll() - // 상품,카테고리,태그 조회 / 상품 파일 다운로드(테스트용) / 상품 상세 조회 / 상품 상세-작가 정보 조회 / 메인페이지에서 주제별 상품 조회 / 검색 / 상품별 찜 개수 조회 - 로그인 없이 접근 허용 - .requestMatchers(HttpMethod.GET, "/api/products","/api/products/*", "/api/categories","/api/tags", "/api/products/images/download/{productUuid}","/api/products/{productUuid}/*", "/api/search", "/api/wishlist/{productUuid}/count").permitAll() - // 상품 찜 등록, 삭제 - 로그인한 유저만 접근 가능 - .requestMatchers("/api/wishlist/{productUuid}").authenticated() + // 상품,카테고리,태그 조회 / 상품 파일 다운로드(테스트용) / 상품 상세 조회 / 상품 상세-작가 정보 조회 / 메인페이지에서 주제별 상품 조회 / 검색 / 상품별 찜 개수 조회 / 상품 Q&A 조회- 로그인 없이 접근 허용 + .requestMatchers(HttpMethod.GET, "/api/products","/api/products/*", "/api/categories","/api/tags", "/api/products/images/download/{productUuid}","/api/products/{productUuid}/*", "/api/search", "/api/wishlist/{productUuid}/count", "/api/products/qna/{productUuid}/{productQnaId}", "/api/products/qna/{productUuid}/list").permitAll() + // 상품 찜 등록, 삭제 / 상품 Q&A 등록 - 로그인한 유저만 접근 가능 + .requestMatchers("/api/wishlist/{productUuid}", "/api/products/qna/{productUuid}").authenticated() // 상품 등록, 수정, 삭제 / 상품 이미지 업로드 / 작가 사업자 정보 조회 - ARTIST, ADMIN, ROOT만 접근 가능 .requestMatchers("/api/products", "/api/products/*", "/api/artist/business-info").hasAnyRole("ARTIST", "ADMIN", "ROOT") // 카테고리,태그 등록, 수정, 삭제 - ADMIN, ROOT만 접근 가능 diff --git a/src/test/java/com/back/domain/product/qna/controller/ProductQnaControllerTest.java b/src/test/java/com/back/domain/product/qna/controller/ProductQnaControllerTest.java new file mode 100644 index 00000000..fd4b86a1 --- /dev/null +++ b/src/test/java/com/back/domain/product/qna/controller/ProductQnaControllerTest.java @@ -0,0 +1,221 @@ +package com.back.domain.product.qna.controller; + +import com.back.domain.product.qna.dto.request.ProductQnaRequestDto; +import com.back.domain.product.qna.dto.response.ProductQnaListResponseDto; +import com.back.domain.product.qna.dto.response.ProductQnaResponseDto; +import com.back.domain.product.qna.service.ProductQnaService; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.global.s3.S3Service; +import com.back.global.s3.UploadResultResponse; +import com.back.global.security.auth.CustomUserDetails; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DisplayName("상품 Q&A ProductQnaController 통합 테스트") +public class ProductQnaControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ProductQnaService productQnaService; + + @MockBean + private S3Service s3Service; + + private CustomUserDetails createTestUserDetails() { + User mockUser = mock(User.class); + + when(mockUser.getId()).thenReturn(1L); + when(mockUser.getEmail()).thenReturn("test@example.com"); + when(mockUser.getPassword()).thenReturn("password"); + when(mockUser.getName()).thenReturn("Test User"); + when(mockUser.getRole()).thenReturn(Role.USER); + + return new CustomUserDetails( + mockUser, + Role.USER + ); + } + + @Test + @DisplayName("상품 Q&A 등록 성공") + void createProductQna_success() throws Exception { + // Given + UUID productUuid = UUID.randomUUID(); + ProductQnaRequestDto requestDto = new ProductQnaRequestDto( + "배송", + "배송 문의합니다.", + "상품 배송이 언제쯤 시작될까요?", + Collections.emptyList() + ); + UUID createdQnaUuid = UUID.randomUUID(); + + when(productQnaService.createProductQna(eq(productUuid), any(ProductQnaRequestDto.class), any(CustomUserDetails.class))) + .thenReturn(createdQnaUuid); + + // When & Then + mockMvc.perform(post("/api/products/qna/{productUuid}", productUuid) + .with(csrf()) + .with(user(createTestUserDetails())) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("상품 Q&A가 성공적으로 등록되었습니다.")) + .andExpect(jsonPath("$.data").value(createdQnaUuid.toString())); + } + + @Test + @DisplayName("상품 Q&A 상세 조회 성공") + void getProductQnaDetail_success() throws Exception { + // Given + UUID productUuid = UUID.randomUUID(); + Long qnaId = 1L; + ProductQnaResponseDto responseDto = new ProductQnaResponseDto( + qnaId, + "배송", + "배송 문의합니다.", + "상품 배송이 언제쯤 시작될까요?", + "Test User", + "23.10.26", + List.of(new UploadResultResponse("http://example.com/image.jpg", null, "s3key", "image.jpg")) + ); + + when(productQnaService.getProductQnaDetail(eq(qnaId))) + .thenReturn(responseDto); + + // When & Then + mockMvc.perform(get("/api/products/qna/{productUuid}/{productQnaId}", productUuid, qnaId) + .with(user(createTestUserDetails()))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("상품 Q&A 상세 조회 성공")) + .andExpect(jsonPath("$.data.id").value(qnaId)) + .andExpect(jsonPath("$.data.qnaCategory").value("배송")) + .andExpect(jsonPath("$.data.qnaTitle").value("배송 문의합니다.")) + .andExpect(jsonPath("$.data.qnaDescription").value("상품 배송이 언제쯤 시작될까요?")) + .andExpect(jsonPath("$.data.authorName").value("Test User")) + .andExpect(jsonPath("$.data.createDate").value("23.10.26")) + .andExpect(jsonPath("$.data.qnaImages[0].url").value("http://example.com/image.jpg")); + } + + @Test + @DisplayName("상품 Q&A 목록 조회 성공 (페이지네이션)") + void getProductQnaList_success() throws Exception { + // Given + UUID productUuid = UUID.randomUUID(); + int page = 1; + int size = 10; + + ProductQnaResponseDto qna1 = new ProductQnaResponseDto( + 1L, "배송", "배송 문의1", "내용1", "User1", "23.10.26", Collections.emptyList()); + ProductQnaResponseDto qna2 = new ProductQnaResponseDto( + 2L, "상품", "상품 문의2", "내용2", "User2", "23.10.25", Collections.emptyList()); + + ProductQnaListResponseDto listResponseDto = new ProductQnaListResponseDto( + page, + 1, + size, + 2L, + List.of(qna1, qna2) + ); + + when(productQnaService.getProductQnaList(eq(productUuid), eq("전체"), eq(page), eq(size))) + .thenReturn(listResponseDto); + + // When & Then + mockMvc.perform(get("/api/products/qna/{productUuid}/list", productUuid) + .param("qnaCategory", "전체") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .with(user(createTestUserDetails()))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("상품 Q&A 목록 조회 성공")) + .andExpect(jsonPath("$.data.currentPage").value(page)) + .andExpect(jsonPath("$.data.totalPages").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(size)) + .andExpect(jsonPath("$.data.totalElements").value(2L)) + .andExpect(jsonPath("$.data.qnaList[0].id").value(1L)) + .andExpect(jsonPath("$.data.qnaList[0].qnaTitle").value("배송 문의1")) + .andExpect(jsonPath("$.data.qnaList[1].id").value(2L)) + .andExpect(jsonPath("$.data.qnaList[1].qnaTitle").value("상품 문의2")); + } + + @Test + @DisplayName("상품 Q&A 목록 조회 성공 (카테고리 필터링)") + void getProductQnaList_filterByCategory_success() throws Exception { + // Given + UUID productUuid = UUID.randomUUID(); + int page = 1; + int size = 10; + String qnaCategory = "배송"; + + ProductQnaResponseDto qna1 = new ProductQnaResponseDto( + 1L, "배송", "배송 문의1", "내용1", "User1", "23.10.26", Collections.emptyList()); + + ProductQnaListResponseDto listResponseDto = new ProductQnaListResponseDto( + page, + 1, + size, + 1L, + List.of(qna1) + ); + + when(productQnaService.getProductQnaList(eq(productUuid), eq(qnaCategory), eq(page), eq(size))) + .thenReturn(listResponseDto); + + // When & Then + mockMvc.perform(get("/api/products/qna/{productUuid}/list", productUuid) + .param("qnaCategory", qnaCategory) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .with(user(createTestUserDetails()))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("상품 Q&A 목록 조회 성공")) + .andExpect(jsonPath("$.data.currentPage").value(page)) + .andExpect(jsonPath("$.data.totalPages").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(size)) + .andExpect(jsonPath("$.data.totalElements").value(1L)) + .andExpect(jsonPath("$.data.qnaList[0].id").value(1L)) + .andExpect(jsonPath("$.data.qnaList[0].qnaCategory").value("배송")) + .andExpect(jsonPath("$.data.qnaList[0].qnaTitle").value("배송 문의1")); + } +} \ No newline at end of file